Compare commits

..

No commits in common. "db8452d1eac3d98108e3c3229597fb53fcb558e1" and "8ec6268a1e6485e2261a1e738c579cf5cd737b29" have entirely different histories.

101 changed files with 408 additions and 289674 deletions

3
.gitignore vendored
View File

@ -15,9 +15,6 @@ src/main/resources/application-local.yml
tokens/ tokens/
*.log *.log
# yt-dlp 다운로드 캐시(영상 파일은 커밋하지 않음)
/downloads/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- list [ref=e12]:
- listitem [ref=e13]:
- link [ref=e14] [cursor=pointer]:
- /url: /
- img [ref=e15]
- listitem [ref=e20]:
- link [ref=e21] [cursor=pointer]:
- /url: /channels
- img [ref=e22]
- listitem [ref=e27]:
- link [ref=e28] [cursor=pointer]:
- /url: /videos
- img [ref=e29]
- listitem [ref=e32]:
- link [ref=e33] [cursor=pointer]:
- /url: /collection
- img [ref=e34]
- listitem [ref=e36]:
- link [ref=e37] [cursor=pointer]:
- /url: /discover
- img [ref=e38]
- listitem [ref=e45]:
- link [ref=e46] [cursor=pointer]:
- /url: /board
- img [ref=e47]
- listitem [ref=e49]:
- link [ref=e50] [cursor=pointer]:
- /url: /publish
- img [ref=e51]
- listitem [ref=e54]:
- link [ref=e55] [cursor=pointer]:
- /url: /production
- img [ref=e56]
- listitem [ref=e61]:
- link [ref=e62] [cursor=pointer]:
- /url: "#"
- img [ref=e63]
- button [ref=e69]:
- img [ref=e70]
- main [ref=e73]:
- generic [ref=e75]:
- generic [ref=e76]:
- heading "작업 보드" [level=1] [ref=e77]
- paragraph [ref=e78]: 카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.
- button "새로고침" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 새로고침

View File

@ -1,112 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- list [ref=e12]:
- listitem [ref=e13]:
- link [ref=e14] [cursor=pointer]:
- /url: /
- img [ref=e15]
- listitem [ref=e20]:
- link [ref=e21] [cursor=pointer]:
- /url: /channels
- img [ref=e22]
- listitem [ref=e27]:
- link [ref=e28] [cursor=pointer]:
- /url: /videos
- img [ref=e29]
- listitem [ref=e32]:
- link [ref=e33] [cursor=pointer]:
- /url: /collection
- img [ref=e34]
- listitem [ref=e36]:
- link [ref=e37] [cursor=pointer]:
- /url: /discover
- img [ref=e38]
- listitem [ref=e45]:
- link [ref=e46] [cursor=pointer]:
- /url: /board
- img [ref=e47]
- listitem [ref=e49]:
- link [ref=e50] [cursor=pointer]:
- /url: /publish
- img [ref=e51]
- listitem [ref=e54]:
- link [ref=e55] [cursor=pointer]:
- /url: /production
- img [ref=e56]
- listitem [ref=e61]:
- link [ref=e62] [cursor=pointer]:
- /url: "#"
- img [ref=e63]
- button [ref=e69]:
- img [ref=e70]
- main [ref=e73]:
- generic [ref=e74]:
- generic [ref=e76]:
- generic [ref=e77]:
- heading "Dashboard" [level=1] [ref=e78]
- paragraph [ref=e79]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- button "새로고침" [ref=e80] [cursor=pointer]:
- img [ref=e81]
- text: 새로고침
- generic [ref=e86]:
- link "수집 영상 - 채널 - · 검색 -" [ref=e87] [cursor=pointer]:
- /url: /collection
- generic [ref=e88]:
- generic [ref=e89]:
- paragraph [ref=e90]: 수집 영상
- heading "-" [level=3] [ref=e91]
- img [ref=e93]
- generic [ref=e95]: 채널 - · 검색 -
- link "미검토 (NEW) - 검토중 -" [ref=e96] [cursor=pointer]:
- /url: /collection?status=NEW
- generic [ref=e97]:
- generic [ref=e98]:
- paragraph [ref=e99]: 미검토 (NEW)
- heading "-" [level=3] [ref=e100]
- img [ref=e102]
- generic [ref=e105]: 검토중 -
- link "작업대상 (TARGET) - 재가공 대상" [ref=e106] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e107]:
- generic [ref=e108]:
- paragraph [ref=e109]: 작업대상 (TARGET)
- heading "-" [level=3] [ref=e110]
- img [ref=e112]
- generic [ref=e117]: 재가공 대상
- link "완료 (DONE) - 제외 -" [ref=e118] [cursor=pointer]:
- /url: /collection?status=DONE
- generic [ref=e119]:
- generic [ref=e120]:
- paragraph [ref=e121]: 완료 (DONE)
- heading "-" [level=3] [ref=e122]
- img [ref=e124]
- generic [ref=e127]: 제외 -
- link "발행완료 - 대기 - · 작성중 -" [ref=e128] [cursor=pointer]:
- /url: /publish
- generic [ref=e129]:
- generic [ref=e130]:
- paragraph [ref=e131]: 발행완료
- heading "-" [level=3] [ref=e132]
- img [ref=e134]
- generic [ref=e137]: 대기 - · 작성중 -
- heading "파이프라인 현황" [level=3] [ref=e140]
- generic [ref=e143]:
- generic [ref=e145]:
- heading "🚀 떡상 후보 TOP" [level=3] [ref=e146]
- link "발굴 →" [ref=e147] [cursor=pointer]:
- /url: /discover
- generic [ref=e151]:
- heading "발행 현황" [level=3] [ref=e152]
- link "발행 큐 →" [ref=e153] [cursor=pointer]:
- /url: /publish
- generic [ref=e154]:
- generic [ref=e156]:
- heading "카테고리 분포" [level=3] [ref=e157]
- link "수집함 →" [ref=e158] [cursor=pointer]:
- /url: /collection
- heading "수집 출처 & 포맷" [level=3] [ref=e162]

View File

@ -1,167 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: H
- generic [ref=e6]:
- text: h-lab
- generic [ref=e7]: yanalyst
- generic [ref=e8]:
- generic [ref=e9]: 분석
- link "대시보드" [ref=e10] [cursor=pointer]:
- /url: "#"
- img [ref=e11]
- text: 대시보드
- link "발굴" [ref=e16] [cursor=pointer]:
- /url: "#"
- img [ref=e17]
- text: 발굴
- link "채널" [ref=e20] [cursor=pointer]:
- /url: "#"
- img [ref=e21]
- text: 채널
- generic [ref=e24]: 파이프라인
- link "수집함" [ref=e25] [cursor=pointer]:
- /url: "#"
- img [ref=e26]
- text: 수집함
- link "칸반 보드" [ref=e28] [cursor=pointer]:
- /url: "#"
- img [ref=e29]
- text: 칸반 보드
- link "재가공" [ref=e33] [cursor=pointer]:
- /url: "#"
- img [ref=e34]
- text: 재가공
- link "발행 큐" [ref=e36] [cursor=pointer]:
- /url: "#"
- img [ref=e37]
- text: 발행 큐
- generic [ref=e41]:
- generic [ref=e42]: hehiho
- text: 개인 작업공간
- main [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]:
- heading "대시보드" [level=1] [ref=e46]
- paragraph [ref=e47]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- button "새로고침" [ref=e48] [cursor=pointer]:
- img [ref=e49]
- text: 새로고침
- generic [ref=e52]:
- generic [ref=e53]:
- img [ref=e55]
- generic [ref=e58]: 수집 영상
- generic [ref=e59]: "623"
- generic [ref=e60]: 채널 623 · 검색 0
- generic [ref=e61]:
- img [ref=e63]
- generic [ref=e66]: 미검토 (NEW)
- generic [ref=e67]: "623"
- generic [ref=e68]: 검토 대기
- generic [ref=e70]:
- img [ref=e72]
- generic [ref=e75]: 작업대상
- generic [ref=e76]: "0"
- generic [ref=e77]: 재가공 대상
- generic [ref=e78]:
- img [ref=e80]
- generic [ref=e82]: 완료 (DONE)
- generic [ref=e83]: "0"
- generic [ref=e84]: 제외 0
- generic [ref=e85]:
- img [ref=e87]
- generic [ref=e89]: 발행완료
- generic [ref=e90]: "0"
- generic [ref=e91]: 대기 0 · 작성 0
- generic [ref=e92]:
- generic [ref=e93]:
- generic [ref=e94]:
- heading "파이프라인 현황" [level=3] [ref=e95]
- generic [ref=e96]: 총 623건 · 제외 0
- generic [ref=e97]:
- generic [ref=e98]: 미검토
- generic [ref=e101]: 623 · 100%
- generic [ref=e102]:
- generic [ref=e103]: 검토중
- generic [ref=e105]: 0 · 0%
- generic [ref=e106]:
- generic [ref=e107]: 작업대상
- generic [ref=e109]: 0 · 0%
- generic [ref=e110]:
- generic [ref=e111]: 완료
- generic [ref=e113]: 0 · 0%
- generic [ref=e114]:
- generic [ref=e115]: 발행완료
- generic [ref=e117]: 0 · 0%
- generic [ref=e118]:
- generic [ref=e119]:
- heading "발행 현황" [level=3] [ref=e120]
- link "발행 큐 →" [ref=e121] [cursor=pointer]:
- /url: "#"
- generic [ref=e122]:
- generic [ref=e123]: 작성중
- generic [ref=e124]: 0 · 0%
- generic [ref=e125]:
- generic [ref=e126]: 발행대기
- generic [ref=e127]: 0 · 0%
- generic [ref=e128]:
- generic [ref=e129]: 발행완료
- generic [ref=e130]: 0 · 0%
- generic [ref=e131]: 발행 패키지가 없습니다
- generic [ref=e132]:
- generic [ref=e133]:
- generic [ref=e134]:
- heading "🔥 떡상 후보 TOP" [level=3] [ref=e135]
- link "발굴 →" [ref=e136] [cursor=pointer]:
- /url: "#"
- generic [ref=e137]:
- generic [ref=e138]: "1"
- generic [ref=e140]:
- generic [ref=e141]: 悪い犬はいない でも悪い飼い主はいる…
- generic [ref=e142]: ショ‍ート · 6일 전 · 조회 21,777,275
- generic [ref=e143]: 378.1x
- generic [ref=e144]:
- generic [ref=e145]: "2"
- generic [ref=e147]:
- generic [ref=e148]: 人生で一度は道徳にぼわなきゃ…
- generic [ref=e149]: ショート · 6일 전 · 조회 18,202,665
- generic [ref=e150]: 314.3x
- generic [ref=e151]:
- generic [ref=e152]: "3"
- generic [ref=e154]:
- generic [ref=e155]: 姫は俺たちが守る
- generic [ref=e156]: ショート · 6일 전 · 조회 16,481,103
- generic [ref=e157]: 285.8x
- generic [ref=e158]:
- generic [ref=e159]: "4"
- generic [ref=e161]:
- generic [ref=e162]: パパの育児はいつも優しいわけで…
- generic [ref=e163]: ショート · 6일 전 · 조회 15,389,420
- generic [ref=e164]: 264.6x
- generic [ref=e165]:
- generic [ref=e166]: "5"
- generic [ref=e168]:
- generic [ref=e169]: レーシング史上最高にロマンな…
- generic [ref=e170]: ショート · 6일 전 · 조회 14,704,744
- generic [ref=e171]: 264.0x
- generic [ref=e172]:
- generic [ref=e173]:
- heading "수집 출처 & 포맷" [level=3] [ref=e174]
- link "수집함 →" [ref=e175] [cursor=pointer]:
- /url: "#"
- generic [ref=e176]:
- generic [ref=e177]: 채널 수집
- generic [ref=e178]: 623 · 100%
- generic [ref=e179]:
- generic [ref=e180]: 검색 수집
- generic [ref=e181]: 0 · 0%
- generic [ref=e182]:
- generic [ref=e183]: Shorts
- generic [ref=e184]: 622 · 100%
- generic [ref=e185]:
- generic [ref=e186]: 롱폼
- generic [ref=e187]: 1 · 0%
- heading "카테고리 분포" [level=3] [ref=e189]
- generic [ref=e190]:
- generic [ref=e191]: 미분류
- generic [ref=e192]: 623 · 100%

View File

@ -1,170 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: H
- generic [ref=e6]:
- text: h-lab
- generic [ref=e7]: yanalyst
- generic [ref=e8]:
- generic [ref=e9]: 분석
- link "대시보드" [ref=e10] [cursor=pointer]:
- /url: "#"
- img [ref=e11]
- text: 대시보드
- link "발굴" [ref=e16] [cursor=pointer]:
- /url: "#"
- img [ref=e17]
- text: 발굴
- link "채널" [ref=e20] [cursor=pointer]:
- /url: "#"
- img [ref=e21]
- text: 채널
- generic [ref=e24]: 파이프라인
- link "수집함" [ref=e25] [cursor=pointer]:
- /url: "#"
- img [ref=e26]
- text: 수집함
- link "칸반 보드" [ref=e28] [cursor=pointer]:
- /url: "#"
- img [ref=e29]
- text: 칸반 보드
- link "재가공" [ref=e33] [cursor=pointer]:
- /url: "#"
- img [ref=e34]
- text: 재가공
- link "발행 큐" [ref=e36] [cursor=pointer]:
- /url: "#"
- img [ref=e37]
- text: 발행 큐
- generic [ref=e41]:
- generic [ref=e42]: hehiho
- text: 개인 작업공간
- main [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]:
- heading "대시보드" [level=1] [ref=e46]
- paragraph [ref=e47]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- generic [ref=e48]:
- button "테마 전환" [ref=e49] [cursor=pointer]:
- img [ref=e50]
- button "새로고침" [ref=e52] [cursor=pointer]:
- img [ref=e53]
- text: 새로고침
- generic [ref=e56]:
- generic [ref=e57]:
- img [ref=e59]
- generic [ref=e62]: 수집 영상
- generic [ref=e63]: "623"
- generic [ref=e64]: 채널 623 · 검색 0
- generic [ref=e65]:
- img [ref=e67]
- generic [ref=e70]: 미검토 (NEW)
- generic [ref=e71]: "623"
- generic [ref=e72]: 검토 대기
- generic [ref=e74]:
- img [ref=e76]
- generic [ref=e79]: 작업대상
- generic [ref=e80]: "0"
- generic [ref=e81]: 재가공 대상
- generic [ref=e82]:
- img [ref=e84]
- generic [ref=e86]: 완료 (DONE)
- generic [ref=e87]: "0"
- generic [ref=e88]: 제외 0
- generic [ref=e89]:
- img [ref=e91]
- generic [ref=e93]: 발행완료
- generic [ref=e94]: "0"
- generic [ref=e95]: 대기 0 · 작성 0
- generic [ref=e96]:
- generic [ref=e97]:
- generic [ref=e98]:
- heading "파이프라인 현황" [level=3] [ref=e99]
- generic [ref=e100]: 총 623건 · 제외 0
- generic [ref=e101]:
- generic [ref=e102]: 미검토
- generic [ref=e105]: 623 · 100%
- generic [ref=e106]:
- generic [ref=e107]: 검토중
- generic [ref=e109]: 0 · 0%
- generic [ref=e110]:
- generic [ref=e111]: 작업대상
- generic [ref=e113]: 0 · 0%
- generic [ref=e114]:
- generic [ref=e115]: 완료
- generic [ref=e117]: 0 · 0%
- generic [ref=e118]:
- generic [ref=e119]: 발행완료
- generic [ref=e121]: 0 · 0%
- generic [ref=e122]:
- generic [ref=e123]:
- heading "발행 현황" [level=3] [ref=e124]
- link "발행 큐 →" [ref=e125] [cursor=pointer]:
- /url: "#"
- generic [ref=e126]:
- generic [ref=e127]: 작성중
- generic [ref=e128]: 0 · 0%
- generic [ref=e129]:
- generic [ref=e130]: 발행대기
- generic [ref=e131]: 0 · 0%
- generic [ref=e132]:
- generic [ref=e133]: 발행완료
- generic [ref=e134]: 0 · 0%
- generic [ref=e135]: 발행 패키지가 없습니다
- generic [ref=e136]:
- generic [ref=e137]:
- generic [ref=e138]:
- heading "🔥 떡상 후보 TOP" [level=3] [ref=e139]
- link "발굴 →" [ref=e140] [cursor=pointer]:
- /url: "#"
- generic [ref=e141]:
- generic [ref=e142]: "1"
- generic [ref=e144]:
- generic [ref=e145]: 悪い犬はいない でも悪い飼い主はいる…
- generic [ref=e146]: ショート · 6일 전 · 조회 21,777,275
- generic [ref=e147]: 378.1x
- generic [ref=e148]:
- generic [ref=e149]: "2"
- generic [ref=e151]:
- generic [ref=e152]: 人生で一度は道徳にぼわなきゃ…
- generic [ref=e153]: ショート · 6일 전 · 조회 18,202,665
- generic [ref=e154]: 314.3x
- generic [ref=e155]:
- generic [ref=e156]: "3"
- generic [ref=e158]:
- generic [ref=e159]: 姫は俺たちが守る
- generic [ref=e160]: ショート · 6일 전 · 조회 16,481,103
- generic [ref=e161]: 285.8x
- generic [ref=e162]:
- generic [ref=e163]: "4"
- generic [ref=e165]:
- generic [ref=e166]: パパの育児はいつも優しいわけで…
- generic [ref=e167]: ショート · 6일 전 · 조회 15,389,420
- generic [ref=e168]: 264.6x
- generic [ref=e169]:
- generic [ref=e170]: "5"
- generic [ref=e172]:
- generic [ref=e173]: レーシング史上最高にロマンな…
- generic [ref=e174]: ショート · 6일 전 · 조회 14,704,744
- generic [ref=e175]: 264.0x
- generic [ref=e176]:
- generic [ref=e177]:
- heading "수집 출처 & 포맷" [level=3] [ref=e178]
- link "수집함 →" [ref=e179] [cursor=pointer]:
- /url: "#"
- generic [ref=e180]:
- generic [ref=e181]: 채널 수집
- generic [ref=e182]: 623 · 100%
- generic [ref=e183]:
- generic [ref=e184]: 검색 수집
- generic [ref=e185]: 0 · 0%
- generic [ref=e186]:
- generic [ref=e187]: Shorts
- generic [ref=e188]: 622 · 100%
- generic [ref=e189]:
- generic [ref=e190]: 롱폼
- generic [ref=e191]: 1 · 0%
- heading "카테고리 분포" [level=3] [ref=e193]
- generic [ref=e194]:
- generic [ref=e195]: 미분류
- generic [ref=e196]: 623 · 100%

View File

@ -1,115 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- generic [ref=e76]:
- heading "Dashboard" [level=1] [ref=e77]
- paragraph [ref=e78]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- button "새로고침" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 새로고침
- generic [ref=e85]:
- link "수집 영상 - 채널 - · 검색 -" [ref=e86] [cursor=pointer]:
- /url: /collection
- generic [ref=e87]:
- generic [ref=e88]:
- paragraph [ref=e89]: 수집 영상
- heading "-" [level=3] [ref=e90]
- img [ref=e92]
- generic [ref=e94]: 채널 - · 검색 -
- link "미검토 (NEW) - 검토중 -" [ref=e95] [cursor=pointer]:
- /url: /collection?status=NEW
- generic [ref=e96]:
- generic [ref=e97]:
- paragraph [ref=e98]: 미검토 (NEW)
- heading "-" [level=3] [ref=e99]
- img [ref=e101]
- generic [ref=e104]: 검토중 -
- link "작업대상 (TARGET) - 재가공 대상" [ref=e105] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e106]:
- generic [ref=e107]:
- paragraph [ref=e108]: 작업대상 (TARGET)
- heading "-" [level=3] [ref=e109]
- img [ref=e111]
- generic [ref=e116]: 재가공 대상
- link "완료 (DONE) - 제외 -" [ref=e117] [cursor=pointer]:
- /url: /collection?status=DONE
- generic [ref=e118]:
- generic [ref=e119]:
- paragraph [ref=e120]: 완료 (DONE)
- heading "-" [level=3] [ref=e121]
- img [ref=e123]
- generic [ref=e126]: 제외 -
- link "발행완료 - 대기 - · 작성중 -" [ref=e127] [cursor=pointer]:
- /url: /publish
- generic [ref=e128]:
- generic [ref=e129]:
- paragraph [ref=e130]: 발행완료
- heading "-" [level=3] [ref=e131]
- img [ref=e133]
- generic [ref=e136]: 대기 - · 작성중 -
- heading "파이프라인 현황" [level=3] [ref=e139]
- generic [ref=e142]:
- generic [ref=e144]:
- heading "🚀 떡상 후보 TOP" [level=3] [ref=e145]
- link "발굴 →" [ref=e146] [cursor=pointer]:
- /url: /discover
- generic [ref=e150]:
- heading "발행 현황" [level=3] [ref=e151]
- link "발행 큐 →" [ref=e152] [cursor=pointer]:
- /url: /publish
- generic [ref=e153]:
- generic [ref=e155]:
- heading "카테고리 분포" [level=3] [ref=e156]
- link "수집함 →" [ref=e157] [cursor=pointer]:
- /url: /collection
- heading "수집 출처 & 포맷" [level=3] [ref=e161]

View File

@ -1,223 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e164]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e166]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e171]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e178]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e183]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e185]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e187]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e190]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e195]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- generic [ref=e76]:
- heading "Dashboard" [level=1] [ref=e77]
- paragraph [ref=e78]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- button "새로고침" [ref=e79] [cursor=pointer]:
- img [ref=e197]
- text: 새로고침
- generic [ref=e85]:
- link "수집 영상 634 채널 634 · 검색 0" [ref=e202] [cursor=pointer]:
- /url: /collection
- generic [ref=e87]:
- generic [ref=e88]:
- paragraph [ref=e89]: 수집 영상
- heading "634" [level=3] [ref=e203]
- img [ref=e204]
- generic [ref=e94]: 채널 634 · 검색 0
- link "미검토 (NEW) 634 검토중 0" [ref=e206] [cursor=pointer]:
- /url: /collection?status=NEW
- generic [ref=e96]:
- generic [ref=e97]:
- paragraph [ref=e98]: 미검토 (NEW)
- heading "634" [level=3] [ref=e207]
- img [ref=e208]
- generic [ref=e104]: 검토중 0
- link "작업대상 (TARGET) 0 재가공 대상" [ref=e211] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e106]:
- generic [ref=e107]:
- paragraph [ref=e108]: 작업대상 (TARGET)
- heading "0" [level=3] [ref=e212]
- img [ref=e213]
- generic [ref=e116]: 재가공 대상
- link "완료 (DONE) 0 제외 0" [ref=e218] [cursor=pointer]:
- /url: /collection?status=DONE
- generic [ref=e118]:
- generic [ref=e119]:
- paragraph [ref=e120]: 완료 (DONE)
- heading "0" [level=3] [ref=e219]
- img [ref=e220]
- generic [ref=e126]: 제외 0
- link "발행완료 0 대기 0 · 작성중 0" [ref=e223] [cursor=pointer]:
- /url: /publish
- generic [ref=e128]:
- generic [ref=e129]:
- paragraph [ref=e130]: 발행완료
- heading "0" [level=3] [ref=e224]
- img [ref=e225]
- generic [ref=e136]: 대기 0 · 작성중 0
- generic [ref=e137]:
- generic [ref=e138]:
- heading "파이프라인 현황" [level=3] [ref=e139]
- generic [ref=e228]: 총 634건 · 제외 0
- generic [ref=e140]:
- generic [ref=e229]:
- generic [ref=e230]: 미검토
- generic [ref=e233]: 634 (100%)
- generic [ref=e234]:
- generic [ref=e235]: 검토중
- generic [ref=e237]: 0 (0%)
- generic [ref=e238]:
- generic [ref=e239]: 작업대상
- generic [ref=e241]: 0 (0%)
- generic [ref=e242]:
- generic [ref=e243]: 완료
- generic [ref=e245]: 0 (0%)
- generic [ref=e246]:
- generic [ref=e247]: 발행완료
- generic [ref=e249]: 0 (0%)
- generic [ref=e142]:
- generic [ref=e143]:
- generic [ref=e144]:
- heading "🚀 떡상 후보 TOP" [level=3] [ref=e145]
- link "발굴 →" [ref=e146] [cursor=pointer]:
- /url: /discover
- generic [ref=e147]:
- link "1 悪い犬はいない でも悪い飼い主はいる ショーツ見る日 · 조회 21,795,725 376.4x" [ref=e250] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=OpmSTCxPzGM
- generic [ref=e251]:
- generic [ref=e252]: "1"
- img [ref=e253]
- generic [ref=e254]:
- generic [ref=e255]: 悪い犬はいない でも悪い飼い主はいる
- generic [ref=e256]: ショーツ見る日 · 조회 21,795,725
- generic [ref=e257]: 376.4x
- link "2 人生で一度は直感に従わなきゃいけない瞬間がある ショーツ見る日 · 조회 18,708,421 323.1x" [ref=e258] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=rtAgbgSgHgc
- generic [ref=e259]:
- generic [ref=e260]: "2"
- img [ref=e261]
- generic [ref=e262]:
- generic [ref=e263]: 人生で一度は直感に従わなきゃいけない瞬間がある
- generic [ref=e264]: ショーツ見る日 · 조회 18,708,421
- generic [ref=e265]: 323.1x
- link "3 姫は俺たちが守る ショーツ見る日 · 조회 16,759,054 289.4x" [ref=e266] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=4HHpI32LnZg
- generic [ref=e267]:
- generic [ref=e268]: "3"
- img [ref=e269]
- generic [ref=e270]:
- generic [ref=e271]: 姫は俺たちが守る
- generic [ref=e272]: ショーツ見る日 · 조회 16,759,054
- generic [ref=e273]: 289.4x
- link "4 レーシング史上最高にロマンな事件🏎️👍 ショーツ見る日 · 조회 14,704,744 264.0x" [ref=e274] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=ax37_8CAw_k
- generic [ref=e275]:
- generic [ref=e276]: "4"
- img [ref=e277]
- generic [ref=e278]:
- generic [ref=e279]: レーシング史上最高にロマンな事件🏎️👍
- generic [ref=e280]: ショーツ見る日 · 조회 14,704,744
- generic [ref=e281]: 264.0x
- link "5 パパの育児はいつも優しいわけじゃないけど ショーツ見る日 · 조회 15,244,867 263.3x" [ref=e282] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=t0qYgTbQ6Fg
- generic [ref=e283]:
- generic [ref=e284]: "5"
- img [ref=e285]
- generic [ref=e286]:
- generic [ref=e287]: パパの育児はいつも優しいわけじゃないけど
- generic [ref=e288]: ショーツ見る日 · 조회 15,244,867
- generic [ref=e289]: 263.3x
- generic [ref=e149]:
- generic [ref=e150]:
- heading "발행 현황" [level=3] [ref=e151]
- link "발행 큐 →" [ref=e152] [cursor=pointer]:
- /url: /publish
- generic [ref=e290]:
- generic [ref=e291]:
- generic [ref=e292]: 작성중
- generic [ref=e294]: 0 (0%)
- generic [ref=e295]:
- generic [ref=e296]: 발행대기
- generic [ref=e298]: 0 (0%)
- generic [ref=e299]:
- generic [ref=e300]: 발행완료
- generic [ref=e302]: 0 (0%)
- paragraph [ref=e304]: 발행 패키지가 없습니다.
- generic [ref=e153]:
- generic [ref=e154]:
- generic [ref=e155]:
- heading "카테고리 분포" [level=3] [ref=e156]
- link "수집함 →" [ref=e157] [cursor=pointer]:
- /url: /collection
- link "미분류 634 (100%)" [ref=e305] [cursor=pointer]:
- /url: /collection
- generic [ref=e306]:
- generic [ref=e307]: 미분류
- generic [ref=e310]: 634 (100%)
- generic [ref=e160]:
- heading "수집 출처 & 포맷" [level=3] [ref=e161]
- generic [ref=e162]:
- generic [ref=e311]: 출처
- link "채널 수집 634 (100%)" [ref=e312] [cursor=pointer]:
- /url: /collection?source=CHANNEL
- generic [ref=e313]:
- generic [ref=e314]: 채널 수집
- generic [ref=e317]: 634 (100%)
- link "검색 수집 0 (0%)" [ref=e318] [cursor=pointer]:
- /url: /collection?source=SEARCH
- generic [ref=e319]:
- generic [ref=e320]: 검색 수집
- generic [ref=e322]: 0 (0%)
- generic [ref=e323]: 포맷
- link "Shorts 633 (100%)" [ref=e324] [cursor=pointer]:
- /url: /collection?shortsOnly=true
- generic [ref=e325]:
- generic [ref=e326]: Shorts
- generic [ref=e329]: 633 (100%)
- link "롱폼 1 (0%)" [ref=e330] [cursor=pointer]:
- /url: /collection
- generic [ref=e331]:
- generic [ref=e332]: 롱폼
- generic [ref=e334]: 1 (0%)

View File

@ -1,115 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- generic [ref=e76]:
- heading "Dashboard" [level=1] [ref=e77]
- paragraph [ref=e78]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- button "새로고침" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 새로고침
- generic [ref=e85]:
- link "수집 영상 - 채널 - · 검색 -" [ref=e86] [cursor=pointer]:
- /url: /collection
- generic [ref=e87]:
- generic [ref=e88]:
- paragraph [ref=e89]: 수집 영상
- heading "-" [level=3] [ref=e90]
- img [ref=e92]
- generic [ref=e94]: 채널 - · 검색 -
- link "미검토 (NEW) - 검토중 -" [ref=e95] [cursor=pointer]:
- /url: /collection?status=NEW
- generic [ref=e96]:
- generic [ref=e97]:
- paragraph [ref=e98]: 미검토 (NEW)
- heading "-" [level=3] [ref=e99]
- img [ref=e101]
- generic [ref=e104]: 검토중 -
- link "작업대상 (TARGET) - 재가공 대상" [ref=e105] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e106]:
- generic [ref=e107]:
- paragraph [ref=e108]: 작업대상 (TARGET)
- heading "-" [level=3] [ref=e109]
- img [ref=e111]
- generic [ref=e116]: 재가공 대상
- link "완료 (DONE) - 제외 -" [ref=e117] [cursor=pointer]:
- /url: /collection?status=DONE
- generic [ref=e118]:
- generic [ref=e119]:
- paragraph [ref=e120]: 완료 (DONE)
- heading "-" [level=3] [ref=e121]
- img [ref=e123]
- generic [ref=e126]: 제외 -
- link "발행완료 - 대기 - · 작성중 -" [ref=e127] [cursor=pointer]:
- /url: /publish
- generic [ref=e128]:
- generic [ref=e129]:
- paragraph [ref=e130]: 발행완료
- heading "-" [level=3] [ref=e131]
- img [ref=e133]
- generic [ref=e136]: 대기 - · 작성중 -
- heading "파이프라인 현황" [level=3] [ref=e139]
- generic [ref=e142]:
- generic [ref=e144]:
- heading "🚀 떡상 후보 TOP" [level=3] [ref=e145]
- link "발굴 →" [ref=e146] [cursor=pointer]:
- /url: /discover
- generic [ref=e150]:
- heading "발행 현황" [level=3] [ref=e151]
- link "발행 큐 →" [ref=e152] [cursor=pointer]:
- /url: /publish
- generic [ref=e153]:
- generic [ref=e155]:
- heading "카테고리 분포" [level=3] [ref=e156]
- link "수집함 →" [ref=e157] [cursor=pointer]:
- /url: /collection
- heading "수집 출처 & 포맷" [level=3] [ref=e161]

View File

@ -1,223 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e164]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e166]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e171]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e178]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e183]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e185]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e187]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e190]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e195]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- generic [ref=e76]:
- heading "Dashboard" [level=1] [ref=e77]
- paragraph [ref=e78]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- button "새로고침" [ref=e79] [cursor=pointer]:
- img [ref=e197]
- text: 새로고침
- generic [ref=e85]:
- link "수집 영상 634 채널 634 · 검색 0" [ref=e202] [cursor=pointer]:
- /url: /collection
- generic [ref=e87]:
- generic [ref=e88]:
- paragraph [ref=e89]: 수집 영상
- heading "634" [level=3] [ref=e203]
- img [ref=e204]
- generic [ref=e94]: 채널 634 · 검색 0
- link "미검토 (NEW) 634 검토중 0" [ref=e206] [cursor=pointer]:
- /url: /collection?status=NEW
- generic [ref=e96]:
- generic [ref=e97]:
- paragraph [ref=e98]: 미검토 (NEW)
- heading "634" [level=3] [ref=e207]
- img [ref=e208]
- generic [ref=e104]: 검토중 0
- link "작업대상 (TARGET) 0 재가공 대상" [ref=e211] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e106]:
- generic [ref=e107]:
- paragraph [ref=e108]: 작업대상 (TARGET)
- heading "0" [level=3] [ref=e212]
- img [ref=e213]
- generic [ref=e116]: 재가공 대상
- link "완료 (DONE) 0 제외 0" [ref=e218] [cursor=pointer]:
- /url: /collection?status=DONE
- generic [ref=e118]:
- generic [ref=e119]:
- paragraph [ref=e120]: 완료 (DONE)
- heading "0" [level=3] [ref=e219]
- img [ref=e220]
- generic [ref=e126]: 제외 0
- link "발행완료 0 대기 0 · 작성중 0" [ref=e223] [cursor=pointer]:
- /url: /publish
- generic [ref=e128]:
- generic [ref=e129]:
- paragraph [ref=e130]: 발행완료
- heading "0" [level=3] [ref=e224]
- img [ref=e225]
- generic [ref=e136]: 대기 0 · 작성중 0
- generic [ref=e137]:
- generic [ref=e138]:
- heading "파이프라인 현황" [level=3] [ref=e139]
- generic [ref=e228]: 총 634건 · 제외 0
- generic [ref=e140]:
- generic [ref=e229]:
- generic [ref=e230]: 미검토
- generic [ref=e233]: 634 (100%)
- generic [ref=e234]:
- generic [ref=e235]: 검토중
- generic [ref=e237]: 0 (0%)
- generic [ref=e238]:
- generic [ref=e239]: 작업대상
- generic [ref=e241]: 0 (0%)
- generic [ref=e242]:
- generic [ref=e243]: 완료
- generic [ref=e245]: 0 (0%)
- generic [ref=e246]:
- generic [ref=e247]: 발행완료
- generic [ref=e249]: 0 (0%)
- generic [ref=e142]:
- generic [ref=e143]:
- generic [ref=e144]:
- heading "🚀 떡상 후보 TOP" [level=3] [ref=e145]
- link "발굴 →" [ref=e146] [cursor=pointer]:
- /url: /discover
- generic [ref=e147]:
- link "1 悪い犬はいない でも悪い飼い主はいる ショーツ見る日 · 조회 21,795,725 376.4x" [ref=e250] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=OpmSTCxPzGM
- generic [ref=e251]:
- generic [ref=e252]: "1"
- img [ref=e253]
- generic [ref=e254]:
- generic [ref=e255]: 悪い犬はいない でも悪い飼い主はいる
- generic [ref=e256]: ショーツ見る日 · 조회 21,795,725
- generic [ref=e257]: 376.4x
- link "2 人生で一度は直感に従わなきゃいけない瞬間がある ショーツ見る日 · 조회 18,708,421 323.1x" [ref=e258] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=rtAgbgSgHgc
- generic [ref=e259]:
- generic [ref=e260]: "2"
- img [ref=e261]
- generic [ref=e262]:
- generic [ref=e263]: 人生で一度は直感に従わなきゃいけない瞬間がある
- generic [ref=e264]: ショーツ見る日 · 조회 18,708,421
- generic [ref=e265]: 323.1x
- link "3 姫は俺たちが守る ショーツ見る日 · 조회 16,759,054 289.4x" [ref=e266] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=4HHpI32LnZg
- generic [ref=e267]:
- generic [ref=e268]: "3"
- img [ref=e269]
- generic [ref=e270]:
- generic [ref=e271]: 姫は俺たちが守る
- generic [ref=e272]: ショーツ見る日 · 조회 16,759,054
- generic [ref=e273]: 289.4x
- link "4 レーシング史上最高にロマンな事件🏎️👍 ショーツ見る日 · 조회 14,704,744 264.0x" [ref=e274] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=ax37_8CAw_k
- generic [ref=e275]:
- generic [ref=e276]: "4"
- img [ref=e277]
- generic [ref=e278]:
- generic [ref=e279]: レーシング史上最高にロマンな事件🏎️👍
- generic [ref=e280]: ショーツ見る日 · 조회 14,704,744
- generic [ref=e281]: 264.0x
- link "5 パパの育児はいつも優しいわけじゃないけど ショーツ見る日 · 조회 15,244,867 263.3x" [ref=e282] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=t0qYgTbQ6Fg
- generic [ref=e283]:
- generic [ref=e284]: "5"
- img [ref=e285]
- generic [ref=e286]:
- generic [ref=e287]: パパの育児はいつも優しいわけじゃないけど
- generic [ref=e288]: ショーツ見る日 · 조회 15,244,867
- generic [ref=e289]: 263.3x
- generic [ref=e149]:
- generic [ref=e150]:
- heading "발행 현황" [level=3] [ref=e151]
- link "발행 큐 →" [ref=e152] [cursor=pointer]:
- /url: /publish
- generic [ref=e290]:
- generic [ref=e291]:
- generic [ref=e292]: 작성중
- generic [ref=e294]: 0 (0%)
- generic [ref=e295]:
- generic [ref=e296]: 발행대기
- generic [ref=e298]: 0 (0%)
- generic [ref=e299]:
- generic [ref=e300]: 발행완료
- generic [ref=e302]: 0 (0%)
- paragraph [ref=e304]: 발행 패키지가 없습니다.
- generic [ref=e153]:
- generic [ref=e154]:
- generic [ref=e155]:
- heading "카테고리 분포" [level=3] [ref=e156]
- link "수집함 →" [ref=e157] [cursor=pointer]:
- /url: /collection
- link "미분류 634 (100%)" [ref=e305] [cursor=pointer]:
- /url: /collection
- generic [ref=e306]:
- generic [ref=e307]: 미분류
- generic [ref=e310]: 634 (100%)
- generic [ref=e160]:
- heading "수집 출처 & 포맷" [level=3] [ref=e161]
- generic [ref=e162]:
- generic [ref=e311]: 출처
- link "채널 수집 634 (100%)" [ref=e312] [cursor=pointer]:
- /url: /collection?source=CHANNEL
- generic [ref=e313]:
- generic [ref=e314]: 채널 수집
- generic [ref=e317]: 634 (100%)
- link "검색 수집 0 (0%)" [ref=e318] [cursor=pointer]:
- /url: /collection?source=SEARCH
- generic [ref=e319]:
- generic [ref=e320]: 검색 수집
- generic [ref=e322]: 0 (0%)
- generic [ref=e323]: 포맷
- link "Shorts 633 (100%)" [ref=e324] [cursor=pointer]:
- /url: /collection?shortsOnly=true
- generic [ref=e325]:
- generic [ref=e326]: Shorts
- generic [ref=e329]: 633 (100%)
- link "롱폼 1 (0%)" [ref=e330] [cursor=pointer]:
- /url: /collection
- generic [ref=e331]:
- generic [ref=e332]: 롱폼
- generic [ref=e334]: 1 (0%)

View File

@ -1,115 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e70]: 테마 전환
- generic [ref=e73]:
- generic [ref=e74]: hehiho
- generic [ref=e75]: 개인 작업공간
- main [ref=e76]:
- generic [ref=e77]:
- generic [ref=e78]:
- heading "수집함 (Collection)" [level=1] [ref=e79]
- paragraph [ref=e80]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- generic [ref=e81]:
- generic [ref=e82]:
- heading "카테고리" [level=3] [ref=e83]
- button [ref=e84] [cursor=pointer]:
- img [ref=e85]
- generic [ref=e87]: 로딩...
- generic [ref=e89]:
- generic [ref=e90] [cursor=pointer]:
- checkbox "🚀 떡상 후보만" [ref=e91]
- generic [ref=e92]: 🚀 떡상 후보만
- generic [ref=e93] [cursor=pointer]:
- checkbox "Shorts만" [ref=e94]
- generic [ref=e95]: Shorts만
- generic [ref=e96] [cursor=pointer]:
- checkbox "⭐ 북마크만" [ref=e97]
- generic [ref=e98]: ⭐ 북마크만
- generic [ref=e99]:
- generic [ref=e100]: 상태
- combobox [ref=e101]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e102]:
- generic [ref=e103]: 카테고리
- combobox [ref=e104]:
- option "전체" [selected]
- generic [ref=e105]:
- generic [ref=e106]: 출처
- combobox [ref=e107]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e108]:
- generic [ref=e109]: 정렬
- combobox [ref=e110]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e111] [cursor=pointer]:
- img [ref=e112]
- text: 새로고침
- table [ref=e118]:
- rowgroup [ref=e119]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e120]:
- columnheader "썸네일" [ref=e121]
- columnheader "제목" [ref=e122]
- columnheader "채널" [ref=e123]
- columnheader "구독자" [ref=e124]
- columnheader "조회수" [ref=e125]
- columnheader "시간당" [ref=e126]
- columnheader "배율" [ref=e127]
- columnheader "상태" [ref=e128]
- columnheader "카테고리" [ref=e129]
- columnheader "관리" [ref=e130]
- rowgroup [ref=e131]:
- row "로딩 중..." [ref=e132]:
- cell "로딩 중..." [ref=e133]

File diff suppressed because it is too large Load Diff

View File

@ -1,115 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- heading "수집함 (Collection)" [level=1] [ref=e75]
- paragraph [ref=e76]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- generic [ref=e77]:
- generic [ref=e78]:
- heading "카테고리" [level=3] [ref=e79]
- button [ref=e80] [cursor=pointer]:
- img [ref=e81]
- generic [ref=e83]: 카테고리가 없습니다. + 로 추가하세요.
- generic [ref=e85]:
- generic [ref=e86] [cursor=pointer]:
- checkbox "🚀 떡상 후보만" [ref=e87]
- generic [ref=e88]: 🚀 떡상 후보만
- generic [ref=e89] [cursor=pointer]:
- checkbox "Shorts만" [ref=e90]
- generic [ref=e91]: Shorts만
- generic [ref=e92] [cursor=pointer]:
- checkbox "⭐ 북마크만" [ref=e93]
- generic [ref=e94]: ⭐ 북마크만
- generic [ref=e95]:
- generic [ref=e96]: 상태
- combobox [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- generic [ref=e99]: 카테고리
- combobox [ref=e100]:
- option "전체" [selected]
- generic [ref=e101]:
- generic [ref=e102]: 출처
- combobox [ref=e103]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e104]:
- generic [ref=e105]: 정렬
- combobox [ref=e106]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e107] [cursor=pointer]:
- img [ref=e108]
- text: 새로고침
- table [ref=e114]:
- rowgroup [ref=e115]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e116]:
- columnheader "썸네일" [ref=e117]
- columnheader "제목" [ref=e118]
- columnheader "채널" [ref=e119]
- columnheader "구독자" [ref=e120]
- columnheader "조회수" [ref=e121]
- columnheader "시간당" [ref=e122]
- columnheader "배율" [ref=e123]
- columnheader "상태" [ref=e124]
- columnheader "카테고리" [ref=e125]
- columnheader "관리" [ref=e126]
- rowgroup [ref=e127]:
- row "로딩 중..." [ref=e128]:
- cell "로딩 중..." [ref=e129]

File diff suppressed because it is too large Load Diff

View File

@ -1,115 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- generic [ref=e78]:
- generic [ref=e79]:
- heading "카테고리" [level=3] [ref=e80]
- button [ref=e81] [cursor=pointer]:
- img [ref=e82]
- generic [ref=e84]: 로딩...
- generic [ref=e86]:
- generic [ref=e87] [cursor=pointer]:
- checkbox "🚀 떡상 후보만" [ref=e88]
- generic [ref=e89]: 🚀 떡상 후보만
- generic [ref=e90] [cursor=pointer]:
- checkbox "Shorts만" [ref=e91]
- generic [ref=e92]: Shorts만
- generic [ref=e93] [cursor=pointer]:
- checkbox "⭐ 북마크만" [ref=e94]
- generic [ref=e95]: ⭐ 북마크만
- generic [ref=e96]:
- generic [ref=e97]: 상태
- combobox [ref=e98]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e99]:
- generic [ref=e100]: 카테고리
- combobox [ref=e101]:
- option "전체" [selected]
- generic [ref=e102]:
- generic [ref=e103]: 출처
- combobox [ref=e104]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e105]:
- generic [ref=e106]: 정렬
- combobox [ref=e107]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e108] [cursor=pointer]:
- img [ref=e109]
- text: 새로고침
- table [ref=e115]:
- rowgroup [ref=e116]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e117]:
- columnheader "썸네일" [ref=e118]
- columnheader "제목" [ref=e119]
- columnheader "채널" [ref=e120]
- columnheader "구독자" [ref=e121]
- columnheader "조회수" [ref=e122]
- columnheader "시간당" [ref=e123]
- columnheader "배율" [ref=e124]
- columnheader "상태" [ref=e125]
- columnheader "카테고리" [ref=e126]
- columnheader "관리" [ref=e127]
- rowgroup [ref=e128]:
- row "로딩 중..." [ref=e129]:
- cell "로딩 중..." [ref=e130]

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- generic [ref=e78]:
- generic [ref=e79]:
- heading "카테고리" [level=3] [ref=e80]
- button [ref=e81] [cursor=pointer]:
- img [ref=e82]
- generic [ref=e84]: 로딩...
- generic [ref=e86]:
- generic [ref=e87] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e88] [cursor=pointer]: Shorts
- generic [ref=e89] [cursor=pointer]: ⭐ 북마크
- generic [ref=e91]:
- text: 상태
- combobox "상태" [ref=e92]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e93]:
- text: 카테고리
- combobox "카테고리" [ref=e94]:
- option "전체" [selected]
- generic [ref=e95]:
- text: 출처
- combobox "출처" [ref=e96]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e97]:
- text: 정렬
- combobox "정렬" [ref=e98]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- table [ref=e107]:
- rowgroup [ref=e108]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e109]:
- columnheader "썸네일" [ref=e110]
- columnheader "제목" [ref=e111]
- columnheader "채널" [ref=e112]
- columnheader "구독자" [ref=e113]
- columnheader "조회수" [ref=e114]
- columnheader "시간당" [ref=e115]
- columnheader "배율" [ref=e116]
- columnheader "상태" [ref=e117]
- columnheader "카테고리" [ref=e118]
- columnheader "관리" [ref=e119]
- rowgroup [ref=e120]:
- row "로딩 중..." [ref=e121]:
- cell "로딩 중..." [ref=e122]

File diff suppressed because it is too large Load Diff

View File

@ -1,112 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85]: 카테고리
- generic [ref=e87]: 로딩...
- button "추가" [ref=e88] [cursor=pointer]:
- img [ref=e89]
- text: 추가
- generic [ref=e91]:
- generic [ref=e92] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e93] [cursor=pointer]: Shorts
- generic [ref=e94] [cursor=pointer]: ⭐ 북마크
- generic [ref=e96]:
- text: 상태
- combobox "상태" [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- text: 카테고리
- combobox "카테고리" [ref=e99]:
- option "전체" [selected]
- generic [ref=e100]:
- text: 출처
- combobox "출처" [ref=e101]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e102]:
- text: 정렬
- combobox "정렬" [ref=e103]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e105] [cursor=pointer]:
- img [ref=e106]
- table [ref=e112]:
- rowgroup [ref=e113]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e114]:
- columnheader "썸네일" [ref=e115]
- columnheader "제목" [ref=e116]
- columnheader "채널" [ref=e117]
- columnheader "구독자" [ref=e118]
- columnheader "조회수" [ref=e119]
- columnheader "시간당" [ref=e120]
- columnheader "배율" [ref=e121]
- columnheader "상태" [ref=e122]
- columnheader "카테고리" [ref=e123]
- columnheader "관리" [ref=e124]
- rowgroup [ref=e125]:
- row "로딩 중..." [ref=e126]:
- cell "로딩 중..." [ref=e127]

File diff suppressed because it is too large Load Diff

View File

@ -1,112 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85]: 카테고리
- generic [ref=e87]: 로딩...
- button "추가" [ref=e88] [cursor=pointer]:
- img [ref=e89]
- text: 추가
- generic [ref=e91]:
- generic [ref=e92] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e93] [cursor=pointer]: Shorts
- generic [ref=e94] [cursor=pointer]: ⭐ 북마크
- generic [ref=e96]:
- text: 상태
- combobox "상태" [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- text: 카테고리
- combobox "카테고리" [ref=e99]:
- option "전체" [selected]
- generic [ref=e100]:
- text: 출처
- combobox "출처" [ref=e101]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e102]:
- text: 정렬
- combobox "정렬" [ref=e103]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e105] [cursor=pointer]:
- img [ref=e106]
- table [ref=e112]:
- rowgroup [ref=e113]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e114]:
- columnheader "썸네일" [ref=e115]
- columnheader "제목" [ref=e116]
- columnheader "채널" [ref=e117]
- columnheader "구독자" [ref=e118]
- columnheader "조회수" [ref=e119]
- columnheader "시간당" [ref=e120]
- columnheader "배율" [ref=e121]
- columnheader "상태" [ref=e122]
- columnheader "카테고리" [ref=e123]
- columnheader "관리" [ref=e124]
- rowgroup [ref=e125]:
- row "로딩 중..." [ref=e126]:
- cell "로딩 중..." [ref=e127]

View File

@ -1,112 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85]: 카테고리
- generic [ref=e87]: 로딩...
- button "추가" [ref=e88] [cursor=pointer]:
- img [ref=e89]
- text: 추가
- generic [ref=e91]:
- generic [ref=e92] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e93] [cursor=pointer]: Shorts
- generic [ref=e94] [cursor=pointer]: ⭐ 북마크
- generic [ref=e96]:
- text: 상태
- combobox "상태" [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- text: 카테고리
- combobox "카테고리" [ref=e99]:
- option "전체" [selected]
- generic [ref=e100]:
- text: 출처
- combobox "출처" [ref=e101]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e102]:
- text: 정렬
- combobox "정렬" [ref=e103]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e105] [cursor=pointer]:
- img [ref=e106]
- table [ref=e112]:
- rowgroup [ref=e113]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e114]:
- columnheader "썸네일" [ref=e115]
- columnheader "제목" [ref=e116]
- columnheader "채널" [ref=e117]
- columnheader "구독자" [ref=e118]
- columnheader "조회수" [ref=e119]
- columnheader "시간당" [ref=e120]
- columnheader "배율" [ref=e121]
- columnheader "상태" [ref=e122]
- columnheader "카테고리" [ref=e123]
- columnheader "관리" [ref=e124]
- rowgroup [ref=e125]:
- row "로딩 중..." [ref=e126]:
- cell "로딩 중..." [ref=e127]

View File

@ -1,110 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85]: 카테고리
- generic [ref=e87]: 로딩...
- button "추가" [ref=e88] [cursor=pointer]:
- img [ref=e89]
- text: 추가
- generic [ref=e91]:
- generic [ref=e92] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e93] [cursor=pointer]: Shorts
- generic [ref=e94] [cursor=pointer]: ⭐ 북마크
- generic [ref=e96]:
- text: 상태
- combobox "상태" [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- text: 카테고리
- combobox "카테고리" [ref=e99]:
- option "전체" [selected]
- generic [ref=e100]:
- text: 출처
- combobox "출처" [ref=e101]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e102]:
- text: 정렬
- combobox "정렬" [ref=e103]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e105] [cursor=pointer]:
- img [ref=e106]
- table [ref=e112]:
- rowgroup [ref=e113]:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e114]:
- columnheader "영상" [ref=e115]
- columnheader "구독자" [ref=e116]
- columnheader "조회수" [ref=e117]
- columnheader "시간당" [ref=e118]
- columnheader "배율" [ref=e119]
- columnheader "상태" [ref=e120]
- columnheader "카테고리" [ref=e121]
- columnheader "관리" [ref=e122]
- rowgroup [ref=e123]:
- row "로딩 중..." [ref=e124]:
- cell "로딩 중..." [ref=e125]

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "칸반 보드" [level=1] [ref=e76]
- paragraph [ref=e77]: 카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.
- generic [ref=e78]:
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- button "새로고침" [ref=e83] [cursor=pointer]:
- img [ref=e84]
- text: 새로고침

File diff suppressed because it is too large Load Diff

View File

@ -1,75 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "발행 큐" [level=1] [ref=e76]
- paragraph [ref=e77]: 재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e83]:
- button "전체" [ref=e84] [cursor=pointer]
- button "작성중" [ref=e85] [cursor=pointer]
- button "발행대기" [ref=e86] [cursor=pointer]
- button "발행완료" [ref=e87] [cursor=pointer]
- table [ref=e90]:
- rowgroup [ref=e91]:
- row "상태 플랫폼 제목 예약 발행 URL 관리" [ref=e92]:
- columnheader "상태" [ref=e93]
- columnheader "플랫폼" [ref=e94]
- columnheader "제목" [ref=e95]
- columnheader "예약" [ref=e96]
- columnheader "발행 URL" [ref=e97]
- columnheader "관리" [ref=e98]
- rowgroup [ref=e99]:
- row "로딩 중..." [ref=e100]:
- cell "로딩 중..." [ref=e101]

View File

@ -1,157 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- link [ref=e76] [cursor=pointer]:
- /url: /collection
- img [ref=e77]
- heading "재가공 작업공간" [level=1] [ref=e79]
- paragraph [ref=e80]: Shorts · 상태 NEW · 배율 0.3x
- generic [ref=e81]:
- generic [ref=e82]:
- iframe [ref=e84]:
- generic [active] [ref=f1e1]:
- generic "YouTube 동영상 플레이어" [ref=f1e3]
- generic [ref=f1e5]:
- generic:
- generic:
- button "동영상 재생" [ref=f1e10] [cursor=pointer]:
- generic [ref=f1e13]:
- img
- button "재생 컨트롤 숨기기" [ref=f1e14] [cursor=pointer]
- generic [ref=f1e21]:
- generic [ref=f1e22]:
- link "家を破壊してしまった修理業者" [ref=f1e23] [cursor=pointer]:
- /url: https://www.youtube.com/shorts/DPJ-YGlAtao
- link "タイムアタック" [ref=f1e24] [cursor=pointer]:
- /url: /channel/UCZqGv6vFFQ0xfXxb9NaexrQ
- generic [ref=f1e25]: タイムアタック
- generic [ref=f1e26]:
- button "thumbnail-image" [ref=f1e27] [cursor=pointer]:
- img "thumbnail-image" [ref=f1e28]
- generic [ref=f1e30]:
- generic: タイムアタック
- generic: 구독자 41만명
- generic [ref=e85]:
- generic [ref=e86]: 家を破壊してしまった修理業者
- generic [ref=e87]: タイムアタック
- generic [ref=e88]:
- generic [ref=e89]: 👁 109,222
- generic [ref=e90]: 👤 410,000
- generic [ref=e91]: ⚡ 515.2/h
- link "YouTube에서 열기 →" [ref=e92] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=DPJ-YGlAtao
- generic [ref=e93]:
- generic [ref=e94]:
- generic [ref=e95]:
- heading "원본 스크립트" [level=3] [ref=e96]
- generic [ref=e97]:
- combobox "전사 언어(자동감지가 틀릴 때 강제)" [ref=e98]:
- option "자동감지" [selected]
- option "한국어"
- option "English"
- option "中文"
- option "日本語"
- generic [ref=e99] [cursor=pointer]:
- img [ref=e100]
- text: 영상 업로드·전사
- button "URL자막" [ref=e103] [cursor=pointer]:
- img [ref=e104]
- text: URL자막
- button "에디터로 복사" [ref=e108] [cursor=pointer]:
- img [ref=e109]
- text: 에디터로 복사
- textbox "‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요." [ref=e112]
- generic [ref=e113]:
- generic [ref=e114]:
- heading "재작성 (내 버전)" [level=3] [ref=e115]
- button "저장" [ref=e116] [cursor=pointer]:
- img [ref=e117]
- text: 저장
- textbox "원본을 참고해 각색/수정한 스크립트를 여기에 작성하세요. 저장하면 상태가 TARGET(작업대상)으로 바뀝니다." [ref=e121]
- generic [ref=e122]:
- heading "📤 발행 준비" [level=3] [ref=e124]
- generic [ref=e125]:
- generic [ref=e126]:
- text: 발행 제목
- textbox "업로드할 새 제목" [ref=e127]
- generic [ref=e128]:
- text: 설명
- textbox "영상 설명" [ref=e129]
- generic [ref=e130]:
- text: 해시태그
- 'textbox "#태그1 #태그2" [ref=e131]'
- generic [ref=e132]:
- generic [ref=e133]:
- text: 플랫폼
- combobox [ref=e134]:
- option "YouTube" [selected]
- option "TikTok"
- option "Instagram Reels"
- generic [ref=e135]:
- text: 예약 일시
- textbox [ref=e136]
- generic [ref=e137]:
- text: 상태
- combobox [ref=e138]:
- option "작성중" [selected]
- option "발행대기"
- option "발행완료"
- generic [ref=e139]:
- button "발행안 저장" [ref=e140] [cursor=pointer]:
- img [ref=e141]
- text: 발행안 저장
- button "발행 완료(URL 기록)" [ref=e145] [cursor=pointer]:
- img [ref=e146]
- text: 발행 완료(URL 기록)
- link "발행 큐 →" [ref=e149] [cursor=pointer]:
- /url: /publish

View File

@ -1,159 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e150]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e152]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e157]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e164]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e169]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e171]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e173]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e176]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e181]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- link [ref=e76] [cursor=pointer]:
- /url: /collection
- img [ref=e183]
- heading "재가공 작업공간" [level=1] [ref=e79]
- paragraph [ref=e80]: Shorts · 상태 NEW · 배율 0.3x
- generic [ref=e81]:
- generic [ref=e82]:
- iframe [ref=e84]:
- generic [active] [ref=f1e1]:
- generic "YouTube 동영상 플레이어" [ref=f1e3]
- generic [ref=f1e5]:
- generic:
- generic:
- button "동영상 재생" [ref=f1e10] [cursor=pointer]:
- generic [ref=f1e13]:
- img
- button "재생 컨트롤 숨기기" [ref=f1e14] [cursor=pointer]
- generic [ref=f1e21]:
- generic [ref=f1e22]:
- link "家を破壊してしまった修理業者" [ref=f1e23] [cursor=pointer]:
- /url: https://www.youtube.com/shorts/DPJ-YGlAtao
- link "タイムアタック" [ref=f1e24] [cursor=pointer]:
- /url: /channel/UCZqGv6vFFQ0xfXxb9NaexrQ
- generic [ref=f1e25]: タイムアタック
- generic [ref=f1e26]:
- button "thumbnail-image" [ref=f1e27] [cursor=pointer]:
- img "thumbnail-image" [ref=f1e28]
- generic [ref=f1e30]:
- generic: タイムアタック
- generic: 구독자 41만명
- generic [ref=e85]:
- generic [ref=e86]: 家を破壊してしまった修理業者
- generic [ref=e87]: タイムアタック
- generic [ref=e88]:
- generic [ref=e89]: 👁 109,222
- generic [ref=e90]: 👤 410,000
- generic [ref=e91]: ⚡ 515.2/h
- link "YouTube에서 열기 →" [ref=e92] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=DPJ-YGlAtao
- generic [ref=e93]:
- generic [ref=e94]:
- generic [ref=e95]:
- heading "원본 스크립트" [level=3] [ref=e96]
- generic [ref=e97]:
- combobox "전사 언어(자동감지가 틀릴 때 강제)" [ref=e98]:
- option "자동감지" [selected]
- option "한국어"
- option "English"
- option "中文"
- option "日本語"
- generic [ref=e99] [cursor=pointer]:
- img [ref=e185]
- text: 영상 업로드·전사
- button "URL자막" [ref=e103] [cursor=pointer]:
- img [ref=e188]
- text: URL자막
- button "에디터로 복사" [ref=e108] [cursor=pointer]:
- img [ref=e192]
- text: 에디터로 복사
- textbox "‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요." [ref=e112]
- generic [ref=e113]:
- generic [ref=e114]:
- heading "재작성 (내 버전)" [level=3] [ref=e115]
- button "저장" [ref=e116] [cursor=pointer]:
- img [ref=e195]
- text: 저장
- textbox "원본을 참고해 각색/수정한 스크립트를 여기에 작성하세요. 저장하면 상태가 TARGET(작업대상)으로 바뀝니다." [ref=e121]
- generic [ref=e122]:
- generic [ref=e123]:
- heading "📤 발행 준비" [level=3] [ref=e124]
- generic [ref=e199]: 미작성
- generic [ref=e125]:
- generic [ref=e126]:
- text: 발행 제목
- textbox "업로드할 새 제목" [ref=e127]: 家を破壊してしまった修理業者
- generic [ref=e128]:
- text: 설명
- textbox "영상 설명" [ref=e129]
- generic [ref=e130]:
- text: 해시태그
- 'textbox "#태그1 #태그2" [ref=e131]'
- generic [ref=e132]:
- generic [ref=e133]:
- text: 플랫폼
- combobox [ref=e134]:
- option "YouTube" [selected]
- option "TikTok"
- option "Instagram Reels"
- generic [ref=e135]:
- text: 예약 일시
- textbox [ref=e136]
- generic [ref=e137]:
- text: 상태
- combobox [ref=e138]:
- option "작성중" [selected]
- option "발행대기"
- option "발행완료"
- generic [ref=e139]:
- button "발행안 저장" [ref=e140] [cursor=pointer]:
- img [ref=e200]
- text: 발행안 저장
- button "발행 완료(URL 기록)" [ref=e145] [cursor=pointer]:
- img [ref=e204]
- text: 발행 완료(URL 기록)
- link "발행 큐 →" [ref=e149] [cursor=pointer]:
- /url: /publish

View File

@ -1,107 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- heading "발굴 (Discovery)" [level=1] [ref=e75]
- paragraph [ref=e76]: 수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)
- generic [ref=e78]:
- generic [ref=e79]:
- generic [ref=e80]: 기간
- combobox [ref=e81]:
- option "전체" [selected]
- option "최근 7일"
- option "최근 14일"
- option "최근 30일"
- generic [ref=e82]:
- generic [ref=e83]: 최소 배율
- combobox [ref=e84]:
- option "전체" [selected]
- option "≥ 2x"
- option "≥ 3x"
- option "≥ 5x"
- generic [ref=e85]:
- generic [ref=e86]: 출처
- combobox [ref=e87]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e88]:
- generic [ref=e89]: 정렬
- combobox [ref=e90]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- generic [ref=e91] [cursor=pointer]:
- checkbox "Shorts만" [ref=e92]
- generic [ref=e93]: Shorts만
- generic [ref=e94] [cursor=pointer]:
- checkbox "미처리만(NEW/검토중)" [ref=e95]
- generic [ref=e96]: 미처리만(NEW/검토중)
- button "새로고침" [ref=e97] [cursor=pointer]:
- img [ref=e98]
- text: 새로고침
- table [ref=e104]:
- rowgroup [ref=e105]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 업로드 상태 관리" [ref=e106]:
- columnheader "썸네일" [ref=e107]
- columnheader "제목" [ref=e108]
- columnheader "채널" [ref=e109]
- columnheader "구독자" [ref=e110]
- columnheader "조회수" [ref=e111]
- columnheader "시간당" [ref=e112]
- columnheader "배율" [ref=e113]
- columnheader "업로드" [ref=e114]
- columnheader "상태" [ref=e115]
- columnheader "관리" [ref=e116]
- rowgroup [ref=e117]:
- row "로딩 중..." [ref=e118]:
- cell "로딩 중..." [ref=e119]

File diff suppressed because it is too large Load Diff

View File

@ -1,106 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "발굴" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85] [cursor=pointer]: Shorts
- generic [ref=e86] [cursor=pointer]: 미처리만
- generic [ref=e88]:
- text: 기간
- combobox "기간" [ref=e89]:
- option "전체" [selected]
- option "최근 7일"
- option "최근 14일"
- option "최근 30일"
- generic [ref=e90]:
- text: 최소 배율
- combobox "최소 배율" [ref=e91]:
- option "전체" [selected]
- option "≥ 2x"
- option "≥ 3x"
- option "≥ 5x"
- generic [ref=e92]:
- text: 출처
- combobox "출처" [ref=e93]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e94]:
- text: 정렬
- combobox "정렬" [ref=e95]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e97] [cursor=pointer]:
- img [ref=e98]
- table [ref=e104]:
- rowgroup [ref=e105]:
- row "썸네일 제목 채널 구독자 조회수 시간당 배율 업로드 상태 관리" [ref=e106]:
- columnheader "썸네일" [ref=e107]
- columnheader "제목" [ref=e108]
- columnheader "채널" [ref=e109]
- columnheader "구독자" [ref=e110]
- columnheader "조회수" [ref=e111]
- columnheader "시간당" [ref=e112]
- columnheader "배율" [ref=e113]
- columnheader "업로드" [ref=e114]
- columnheader "상태" [ref=e115]
- columnheader "관리" [ref=e116]
- rowgroup [ref=e117]:
- row "로딩 중..." [ref=e118]:
- cell "로딩 중..." [ref=e119]

File diff suppressed because it is too large Load Diff

View File

@ -1,143 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "Channels" [level=1] [ref=e76]
- paragraph [ref=e77]: Manage the YouTube channels you are tracking.
- generic [ref=e78]:
- generic [ref=e79] [cursor=pointer]:
- checkbox "Select All" [ref=e80]
- text: Select All
- button "Add Channel" [ref=e81] [cursor=pointer]:
- img [ref=e82]
- text: Add Channel
- generic [ref=e83]:
- generic [ref=e84] [cursor=pointer]:
- checkbox [ref=e86]
- generic [ref=e88]:
- img "Thumbnail" [ref=e89]
- generic [ref=e90]:
- heading "タイムアタック" [level=3] [ref=e91]
- generic [ref=e92]: 410,000 Subscribers
- generic [ref=e94]:
- generic [ref=e95]:
- generic [ref=e96]: Videos
- generic [ref=e97]: 1,628
- generic [ref=e98]:
- generic [ref=e99]: Total Views
- generic [ref=e100]: 2,755,528,758
- generic [ref=e101]:
- link "Detail" [ref=e102]:
- /url: /channels/4
- img [ref=e103]
- text: Detail
- link "Visit" [ref=e104]:
- /url: https://www.youtube.com/channel/UCZqGv6vFFQ0xfXxb9NaexrQ
- img [ref=e105]
- text: Visit
- button "Remove" [ref=e109]:
- img [ref=e110]
- text: Remove
- generic [ref=e113] [cursor=pointer]:
- checkbox [ref=e115]
- generic [ref=e117]:
- img "Thumbnail" [ref=e118]
- generic [ref=e119]:
- heading "世界で見つけた話" [level=3] [ref=e120]
- generic [ref=e121]: 110,000 Subscribers
- generic [ref=e122]: "世界で見つけたいろんな話をシェアします。 本動画の一部映像は、その魅力を広く伝える目的で引用しております。 原作者の皆さまには深く敬意を払っており、ご迷惑をおかけする意図はございません。 万が一問題がございましたら、下記メールまでご連絡ください。誠意をもって対応いたします。 公式Instagramhttps://www.instagram.com/fun_meets_fans/ ※公式TikTokはありません。 #感動する話"
- generic [ref=e123]:
- generic [ref=e124]:
- generic [ref=e125]: Videos
- generic [ref=e126]: "442"
- generic [ref=e127]:
- generic [ref=e128]: Total Views
- generic [ref=e129]: 478,168,043
- generic [ref=e130]:
- link "Detail" [ref=e131]:
- /url: /channels/3
- img [ref=e132]
- text: Detail
- link "Visit" [ref=e133]:
- /url: https://www.youtube.com/channel/UCLmd59Z9Jx1P75M5Jvsibtw
- img [ref=e134]
- text: Visit
- button "Remove" [ref=e138]:
- img [ref=e139]
- text: Remove
- generic [ref=e142] [cursor=pointer]:
- checkbox [ref=e144]
- generic [ref=e146]:
- img "Thumbnail" [ref=e147]
- generic [ref=e148]:
- heading "ショーツ見る日" [level=3] [ref=e149]
- generic [ref=e150]: 58,700 Subscribers
- generic [ref=e151]: 素敵な一日をお過ごしください VoiceVox - ずんだもん , 玄野武宏 , 青山龍星 , 春日部つむぎ , 四国めたん
- generic [ref=e152]:
- generic [ref=e153]:
- generic [ref=e154]: Videos
- generic [ref=e155]: "332"
- generic [ref=e156]:
- generic [ref=e157]: Total Views
- generic [ref=e158]: 520,694,186
- generic [ref=e159]:
- link "Detail" [ref=e160]:
- /url: /channels/2
- img [ref=e161]
- text: Detail
- link "Visit" [ref=e162]:
- /url: https://www.youtube.com/channel/UCcqZCdNJD0p5oPnqtBpOHVA
- img [ref=e163]
- text: Visit
- button "Remove" [ref=e167]:
- img [ref=e168]
- text: Remove

View File

@ -1,143 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "Channels" [level=1] [ref=e76]
- paragraph [ref=e77]: Manage the YouTube channels you are tracking.
- generic [ref=e78]:
- generic [ref=e79] [cursor=pointer]:
- checkbox "Select All" [ref=e80]
- text: Select All
- button "Add Channel" [ref=e81] [cursor=pointer]:
- img [ref=e82]
- text: Add Channel
- generic [ref=e83]:
- generic [ref=e84] [cursor=pointer]:
- checkbox [ref=e86]
- generic [ref=e88]:
- img "Thumbnail" [ref=e89]
- generic [ref=e90]:
- heading "タイムアタック" [level=3] [ref=e91]
- generic [ref=e92]: 410,000 Subscribers
- generic [ref=e94]:
- generic [ref=e95]:
- generic [ref=e96]: Videos
- generic [ref=e97]: 1,628
- generic [ref=e98]:
- generic [ref=e99]: Total Views
- generic [ref=e100]: 2,755,528,758
- generic [ref=e101]:
- link "Detail" [ref=e102]:
- /url: /channels/4
- img [ref=e103]
- text: Detail
- link "Visit" [ref=e104]:
- /url: https://www.youtube.com/channel/UCZqGv6vFFQ0xfXxb9NaexrQ
- img [ref=e105]
- text: Visit
- button "Remove" [ref=e109]:
- img [ref=e110]
- text: Remove
- generic [ref=e113] [cursor=pointer]:
- checkbox [ref=e115]
- generic [ref=e117]:
- img "Thumbnail" [ref=e118]
- generic [ref=e119]:
- heading "世界で見つけた話" [level=3] [ref=e120]
- generic [ref=e121]: 110,000 Subscribers
- generic [ref=e122]: "世界で見つけたいろんな話をシェアします。 本動画の一部映像は、その魅力を広く伝える目的で引用しております。 原作者の皆さまには深く敬意を払っており、ご迷惑をおかけする意図はございません。 万が一問題がございましたら、下記メールまでご連絡ください。誠意をもって対応いたします。 公式Instagramhttps://www.instagram.com/fun_meets_fans/ ※公式TikTokはありません。 #感動する話"
- generic [ref=e123]:
- generic [ref=e124]:
- generic [ref=e125]: Videos
- generic [ref=e126]: "442"
- generic [ref=e127]:
- generic [ref=e128]: Total Views
- generic [ref=e129]: 478,168,043
- generic [ref=e130]:
- link "Detail" [ref=e131]:
- /url: /channels/3
- img [ref=e132]
- text: Detail
- link "Visit" [ref=e133]:
- /url: https://www.youtube.com/channel/UCLmd59Z9Jx1P75M5Jvsibtw
- img [ref=e134]
- text: Visit
- button "Remove" [ref=e138]:
- img [ref=e139]
- text: Remove
- generic [ref=e142] [cursor=pointer]:
- checkbox [ref=e144]
- generic [ref=e146]:
- img "Thumbnail" [ref=e147]
- generic [ref=e148]:
- heading "ショーツ見る日" [level=3] [ref=e149]
- generic [ref=e150]: 58,700 Subscribers
- generic [ref=e151]: 素敵な一日をお過ごしください VoiceVox - ずんだもん , 玄野武宏 , 青山龍星 , 春日部つむぎ , 四国めたん
- generic [ref=e152]:
- generic [ref=e153]:
- generic [ref=e154]: Videos
- generic [ref=e155]: "332"
- generic [ref=e156]:
- generic [ref=e157]: Total Views
- generic [ref=e158]: 520,694,186
- generic [ref=e159]:
- link "Detail" [ref=e160]:
- /url: /channels/2
- img [ref=e161]
- text: Detail
- link "Visit" [ref=e162]:
- /url: https://www.youtube.com/channel/UCcqZCdNJD0p5oPnqtBpOHVA
- img [ref=e163]
- text: Visit
- button "Remove" [ref=e167]:
- img [ref=e168]
- text: Remove

View File

@ -1,104 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "발굴" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85] [cursor=pointer]: Shorts
- generic [ref=e86] [cursor=pointer]: 미처리만
- generic [ref=e88]:
- text: 기간
- combobox "기간" [ref=e89]:
- option "전체" [selected]
- option "최근 7일"
- option "최근 14일"
- option "최근 30일"
- generic [ref=e90]:
- text: 최소 배율
- combobox "최소 배율" [ref=e91]:
- option "전체" [selected]
- option "≥ 2x"
- option "≥ 3x"
- option "≥ 5x"
- generic [ref=e92]:
- text: 출처
- combobox "출처" [ref=e93]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e94]:
- text: 정렬
- combobox "정렬" [ref=e95]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e97] [cursor=pointer]:
- img [ref=e98]
- table [ref=e104]:
- rowgroup [ref=e105]:
- row "영상 구독자 조회수 시간당 배율 업로드 상태 관리" [ref=e106]:
- columnheader "영상" [ref=e107]
- columnheader "구독자" [ref=e108]
- columnheader "조회수" [ref=e109]
- columnheader "시간당" [ref=e110]
- columnheader "배율" [ref=e111]
- columnheader "업로드" [ref=e112]
- columnheader "상태" [ref=e113]
- columnheader "관리" [ref=e114]
- rowgroup [ref=e115]:
- row "로딩 중..." [ref=e116]:
- cell "로딩 중..." [ref=e117]

File diff suppressed because it is too large Load Diff

View File

@ -1,277 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- heading "Production" [level=1] [ref=e76]
- paragraph [ref=e77]: Manage video production and rankings.
- button "순위 가져오기" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 순위 가져오기
- generic [ref=e84]:
- heading "Crawl History" [level=3] [ref=e85]
- table [ref=e86]:
- rowgroup [ref=e87]:
- row "ID Crawled Date Top N Videos Count" [ref=e88]:
- columnheader "ID" [ref=e89]
- columnheader "Crawled Date" [ref=e90]
- columnheader "Top N" [ref=e91]
- columnheader "Videos Count" [ref=e92]
- rowgroup [ref=e93]:
- row "42 2026-05-13 02:55:39 40 40" [ref=e94] [cursor=pointer]:
- cell "42" [ref=e95]
- cell "2026-05-13 02:55:39" [ref=e96]
- cell "40" [ref=e97]
- cell "40" [ref=e98]
- row "41 2026-03-30 00:23:03 40 25" [ref=e99] [cursor=pointer]:
- cell "41" [ref=e100]
- cell "2026-03-30 00:23:03" [ref=e101]
- cell "40" [ref=e102]
- cell "25" [ref=e103]
- row "40 2026-03-11 00:18:38 40 40" [ref=e104] [cursor=pointer]:
- cell "40" [ref=e105]
- cell "2026-03-11 00:18:38" [ref=e106]
- cell "40" [ref=e107]
- cell "40" [ref=e108]
- row "39 2026-03-09 00:37:11 40 25" [ref=e109] [cursor=pointer]:
- cell "39" [ref=e110]
- cell "2026-03-09 00:37:11" [ref=e111]
- cell "40" [ref=e112]
- cell "25" [ref=e113]
- row "38 2026-03-09 00:36:04 40 40" [ref=e114] [cursor=pointer]:
- cell "38" [ref=e115]
- cell "2026-03-09 00:36:04" [ref=e116]
- cell "40" [ref=e117]
- cell "40" [ref=e118]
- row "37 2026-02-27 00:20:56 40 25" [ref=e119] [cursor=pointer]:
- cell "37" [ref=e120]
- cell "2026-02-27 00:20:56" [ref=e121]
- cell "40" [ref=e122]
- cell "25" [ref=e123]
- row "36 2026-02-26 03:50:16 40 40" [ref=e124] [cursor=pointer]:
- cell "36" [ref=e125]
- cell "2026-02-26 03:50:16" [ref=e126]
- cell "40" [ref=e127]
- cell "40" [ref=e128]
- row "35 2026-02-24 03:49:39 40 40" [ref=e129] [cursor=pointer]:
- cell "35" [ref=e130]
- cell "2026-02-24 03:49:39" [ref=e131]
- cell "40" [ref=e132]
- cell "40" [ref=e133]
- row "34 2026-02-23 05:07:59 40 40" [ref=e134] [cursor=pointer]:
- cell "34" [ref=e135]
- cell "2026-02-23 05:07:59" [ref=e136]
- cell "40" [ref=e137]
- cell "40" [ref=e138]
- row "33 2026-02-22 00:19:12 40 40" [ref=e139] [cursor=pointer]:
- cell "33" [ref=e140]
- cell "2026-02-22 00:19:12" [ref=e141]
- cell "40" [ref=e142]
- cell "40" [ref=e143]
- row "32 2026-02-19 08:48:23 40 40" [ref=e144] [cursor=pointer]:
- cell "32" [ref=e145]
- cell "2026-02-19 08:48:23" [ref=e146]
- cell "40" [ref=e147]
- cell "40" [ref=e148]
- row "31 2026-02-13 08:30:11 40 40" [ref=e149] [cursor=pointer]:
- cell "31" [ref=e150]
- cell "2026-02-13 08:30:11" [ref=e151]
- cell "40" [ref=e152]
- cell "40" [ref=e153]
- row "30 2026-02-12 02:47:51 40 40" [ref=e154] [cursor=pointer]:
- cell "30" [ref=e155]
- cell "2026-02-12 02:47:51" [ref=e156]
- cell "40" [ref=e157]
- cell "40" [ref=e158]
- row "29 2026-02-11 05:40:48 40 25" [ref=e159] [cursor=pointer]:
- cell "29" [ref=e160]
- cell "2026-02-11 05:40:48" [ref=e161]
- cell "40" [ref=e162]
- cell "25" [ref=e163]
- row "28 2026-02-09 00:43:30 40 40" [ref=e164] [cursor=pointer]:
- cell "28" [ref=e165]
- cell "2026-02-09 00:43:30" [ref=e166]
- cell "40" [ref=e167]
- cell "40" [ref=e168]
- row "27 2026-02-09 00:43:16 40 25" [ref=e169] [cursor=pointer]:
- cell "27" [ref=e170]
- cell "2026-02-09 00:43:16" [ref=e171]
- cell "40" [ref=e172]
- cell "25" [ref=e173]
- row "26 2026-02-05 09:07:51 40 25" [ref=e174] [cursor=pointer]:
- cell "26" [ref=e175]
- cell "2026-02-05 09:07:51" [ref=e176]
- cell "40" [ref=e177]
- cell "25" [ref=e178]
- row "25 2026-02-02 05:33:56 40 40" [ref=e179] [cursor=pointer]:
- cell "25" [ref=e180]
- cell "2026-02-02 05:33:56" [ref=e181]
- cell "40" [ref=e182]
- cell "40" [ref=e183]
- row "24 2026-02-02 01:35:28 40 40" [ref=e184] [cursor=pointer]:
- cell "24" [ref=e185]
- cell "2026-02-02 01:35:28" [ref=e186]
- cell "40" [ref=e187]
- cell "40" [ref=e188]
- row "23 2026-01-30 02:07:22 40 40" [ref=e189] [cursor=pointer]:
- cell "23" [ref=e190]
- cell "2026-01-30 02:07:22" [ref=e191]
- cell "40" [ref=e192]
- cell "40" [ref=e193]
- row "22 2026-01-29 00:59:58 40 25" [ref=e194] [cursor=pointer]:
- cell "22" [ref=e195]
- cell "2026-01-29 00:59:58" [ref=e196]
- cell "40" [ref=e197]
- cell "25" [ref=e198]
- row "21 2026-01-27 00:43:00 40 40" [ref=e199] [cursor=pointer]:
- cell "21" [ref=e200]
- cell "2026-01-27 00:43:00" [ref=e201]
- cell "40" [ref=e202]
- cell "40" [ref=e203]
- row "20 2026-01-26 00:27:11 40 40" [ref=e204] [cursor=pointer]:
- cell "20" [ref=e205]
- cell "2026-01-26 00:27:11" [ref=e206]
- cell "40" [ref=e207]
- cell "40" [ref=e208]
- row "19 2026-01-23 02:48:11 40 40" [ref=e209] [cursor=pointer]:
- cell "19" [ref=e210]
- cell "2026-01-23 02:48:11" [ref=e211]
- cell "40" [ref=e212]
- cell "40" [ref=e213]
- row "18 2026-01-22 03:48:29 40 40" [ref=e214] [cursor=pointer]:
- cell "18" [ref=e215]
- cell "2026-01-22 03:48:29" [ref=e216]
- cell "40" [ref=e217]
- cell "40" [ref=e218]
- row "17 2026-01-22 09:34:17 40 40" [ref=e219] [cursor=pointer]:
- cell "17" [ref=e220]
- cell "2026-01-22 09:34:17" [ref=e221]
- cell "40" [ref=e222]
- cell "40" [ref=e223]
- row "16 2026-01-20 03:54:52 40 40" [ref=e224] [cursor=pointer]:
- cell "16" [ref=e225]
- cell "2026-01-20 03:54:52" [ref=e226]
- cell "40" [ref=e227]
- cell "40" [ref=e228]
- row "15 2026-01-19 00:20:12 40 40" [ref=e229] [cursor=pointer]:
- cell "15" [ref=e230]
- cell "2026-01-19 00:20:12" [ref=e231]
- cell "40" [ref=e232]
- cell "40" [ref=e233]
- row "14 2026-01-16 08:48:51 40 40" [ref=e234] [cursor=pointer]:
- cell "14" [ref=e235]
- cell "2026-01-16 08:48:51" [ref=e236]
- cell "40" [ref=e237]
- cell "40" [ref=e238]
- row "13 2026-01-10 01:51:12 40 40" [ref=e239] [cursor=pointer]:
- cell "13" [ref=e240]
- cell "2026-01-10 01:51:12" [ref=e241]
- cell "40" [ref=e242]
- cell "40" [ref=e243]
- row "12 2026-01-09 17:31:19 40 23" [ref=e244] [cursor=pointer]:
- cell "12" [ref=e245]
- cell "2026-01-09 17:31:19" [ref=e246]
- cell "40" [ref=e247]
- cell "23" [ref=e248]
- row "11 2026-01-09 17:26:05 40 40" [ref=e249] [cursor=pointer]:
- cell "11" [ref=e250]
- cell "2026-01-09 17:26:05" [ref=e251]
- cell "40" [ref=e252]
- cell "40" [ref=e253]
- row "10 2026-01-09 16:59:12 40 40" [ref=e254] [cursor=pointer]:
- cell "10" [ref=e255]
- cell "2026-01-09 16:59:12" [ref=e256]
- cell "40" [ref=e257]
- cell "40" [ref=e258]
- row "9 2026-01-09 16:52:01 40 40" [ref=e259] [cursor=pointer]:
- cell "9" [ref=e260]
- cell "2026-01-09 16:52:01" [ref=e261]
- cell "40" [ref=e262]
- cell "40" [ref=e263]
- row "8 2026-01-09 10:56:59 40 40" [ref=e264] [cursor=pointer]:
- cell "8" [ref=e265]
- cell "2026-01-09 10:56:59" [ref=e266]
- cell "40" [ref=e267]
- cell "40" [ref=e268]
- row "7 40 40" [ref=e269] [cursor=pointer]:
- cell "7" [ref=e270]
- cell [ref=e271]
- cell "40" [ref=e272]
- cell "40" [ref=e273]
- row "6 40 40" [ref=e274] [cursor=pointer]:
- cell "6" [ref=e275]
- cell [ref=e276]
- cell "40" [ref=e277]
- cell "40" [ref=e278]
- row "5 40 40" [ref=e279] [cursor=pointer]:
- cell "5" [ref=e280]
- cell [ref=e281]
- cell "40" [ref=e282]
- cell "40" [ref=e283]
- row "4 40 40" [ref=e284] [cursor=pointer]:
- cell "4" [ref=e285]
- cell [ref=e286]
- cell "40" [ref=e287]
- cell "40" [ref=e288]
- row "3 40 4" [ref=e289] [cursor=pointer]:
- cell "3" [ref=e290]
- cell [ref=e291]
- cell "40" [ref=e292]
- cell "4" [ref=e293]
- row "2 10 5" [ref=e294] [cursor=pointer]:
- cell "2" [ref=e295]
- cell [ref=e296]
- cell "10" [ref=e297]
- cell "5" [ref=e298]
- row "1 10 5" [ref=e299] [cursor=pointer]:
- cell "1" [ref=e300]
- cell [ref=e301]
- cell "10" [ref=e302]
- cell "5" [ref=e303]

View File

@ -1,277 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e75]:
- heading "Production" [level=1] [ref=e76]
- paragraph [ref=e77]: Manage video production and rankings.
- button "순위 가져오기" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 순위 가져오기
- generic [ref=e84]:
- heading "Crawl History" [level=3] [ref=e85]
- table [ref=e86]:
- rowgroup [ref=e87]:
- row "ID Crawled Date Top N Videos Count" [ref=e88]:
- columnheader "ID" [ref=e89]
- columnheader "Crawled Date" [ref=e90]
- columnheader "Top N" [ref=e91]
- columnheader "Videos Count" [ref=e92]
- rowgroup [ref=e93]:
- row "42 2026-05-13 02:55:39 40 40" [ref=e94] [cursor=pointer]:
- cell "42" [ref=e95]
- cell "2026-05-13 02:55:39" [ref=e96]
- cell "40" [ref=e97]
- cell "40" [ref=e98]
- row "41 2026-03-30 00:23:03 40 25" [ref=e99] [cursor=pointer]:
- cell "41" [ref=e100]
- cell "2026-03-30 00:23:03" [ref=e101]
- cell "40" [ref=e102]
- cell "25" [ref=e103]
- row "40 2026-03-11 00:18:38 40 40" [ref=e104] [cursor=pointer]:
- cell "40" [ref=e105]
- cell "2026-03-11 00:18:38" [ref=e106]
- cell "40" [ref=e107]
- cell "40" [ref=e108]
- row "39 2026-03-09 00:37:11 40 25" [ref=e109] [cursor=pointer]:
- cell "39" [ref=e110]
- cell "2026-03-09 00:37:11" [ref=e111]
- cell "40" [ref=e112]
- cell "25" [ref=e113]
- row "38 2026-03-09 00:36:04 40 40" [ref=e114] [cursor=pointer]:
- cell "38" [ref=e115]
- cell "2026-03-09 00:36:04" [ref=e116]
- cell "40" [ref=e117]
- cell "40" [ref=e118]
- row "37 2026-02-27 00:20:56 40 25" [ref=e119] [cursor=pointer]:
- cell "37" [ref=e120]
- cell "2026-02-27 00:20:56" [ref=e121]
- cell "40" [ref=e122]
- cell "25" [ref=e123]
- row "36 2026-02-26 03:50:16 40 40" [ref=e124] [cursor=pointer]:
- cell "36" [ref=e125]
- cell "2026-02-26 03:50:16" [ref=e126]
- cell "40" [ref=e127]
- cell "40" [ref=e128]
- row "35 2026-02-24 03:49:39 40 40" [ref=e129] [cursor=pointer]:
- cell "35" [ref=e130]
- cell "2026-02-24 03:49:39" [ref=e131]
- cell "40" [ref=e132]
- cell "40" [ref=e133]
- row "34 2026-02-23 05:07:59 40 40" [ref=e134] [cursor=pointer]:
- cell "34" [ref=e135]
- cell "2026-02-23 05:07:59" [ref=e136]
- cell "40" [ref=e137]
- cell "40" [ref=e138]
- row "33 2026-02-22 00:19:12 40 40" [ref=e139] [cursor=pointer]:
- cell "33" [ref=e140]
- cell "2026-02-22 00:19:12" [ref=e141]
- cell "40" [ref=e142]
- cell "40" [ref=e143]
- row "32 2026-02-19 08:48:23 40 40" [ref=e144] [cursor=pointer]:
- cell "32" [ref=e145]
- cell "2026-02-19 08:48:23" [ref=e146]
- cell "40" [ref=e147]
- cell "40" [ref=e148]
- row "31 2026-02-13 08:30:11 40 40" [ref=e149] [cursor=pointer]:
- cell "31" [ref=e150]
- cell "2026-02-13 08:30:11" [ref=e151]
- cell "40" [ref=e152]
- cell "40" [ref=e153]
- row "30 2026-02-12 02:47:51 40 40" [ref=e154] [cursor=pointer]:
- cell "30" [ref=e155]
- cell "2026-02-12 02:47:51" [ref=e156]
- cell "40" [ref=e157]
- cell "40" [ref=e158]
- row "29 2026-02-11 05:40:48 40 25" [ref=e159] [cursor=pointer]:
- cell "29" [ref=e160]
- cell "2026-02-11 05:40:48" [ref=e161]
- cell "40" [ref=e162]
- cell "25" [ref=e163]
- row "28 2026-02-09 00:43:30 40 40" [ref=e164] [cursor=pointer]:
- cell "28" [ref=e165]
- cell "2026-02-09 00:43:30" [ref=e166]
- cell "40" [ref=e167]
- cell "40" [ref=e168]
- row "27 2026-02-09 00:43:16 40 25" [ref=e169] [cursor=pointer]:
- cell "27" [ref=e170]
- cell "2026-02-09 00:43:16" [ref=e171]
- cell "40" [ref=e172]
- cell "25" [ref=e173]
- row "26 2026-02-05 09:07:51 40 25" [ref=e174] [cursor=pointer]:
- cell "26" [ref=e175]
- cell "2026-02-05 09:07:51" [ref=e176]
- cell "40" [ref=e177]
- cell "25" [ref=e178]
- row "25 2026-02-02 05:33:56 40 40" [ref=e179] [cursor=pointer]:
- cell "25" [ref=e180]
- cell "2026-02-02 05:33:56" [ref=e181]
- cell "40" [ref=e182]
- cell "40" [ref=e183]
- row "24 2026-02-02 01:35:28 40 40" [ref=e184] [cursor=pointer]:
- cell "24" [ref=e185]
- cell "2026-02-02 01:35:28" [ref=e186]
- cell "40" [ref=e187]
- cell "40" [ref=e188]
- row "23 2026-01-30 02:07:22 40 40" [ref=e189] [cursor=pointer]:
- cell "23" [ref=e190]
- cell "2026-01-30 02:07:22" [ref=e191]
- cell "40" [ref=e192]
- cell "40" [ref=e193]
- row "22 2026-01-29 00:59:58 40 25" [ref=e194] [cursor=pointer]:
- cell "22" [ref=e195]
- cell "2026-01-29 00:59:58" [ref=e196]
- cell "40" [ref=e197]
- cell "25" [ref=e198]
- row "21 2026-01-27 00:43:00 40 40" [ref=e199] [cursor=pointer]:
- cell "21" [ref=e200]
- cell "2026-01-27 00:43:00" [ref=e201]
- cell "40" [ref=e202]
- cell "40" [ref=e203]
- row "20 2026-01-26 00:27:11 40 40" [ref=e204] [cursor=pointer]:
- cell "20" [ref=e205]
- cell "2026-01-26 00:27:11" [ref=e206]
- cell "40" [ref=e207]
- cell "40" [ref=e208]
- row "19 2026-01-23 02:48:11 40 40" [ref=e209] [cursor=pointer]:
- cell "19" [ref=e210]
- cell "2026-01-23 02:48:11" [ref=e211]
- cell "40" [ref=e212]
- cell "40" [ref=e213]
- row "18 2026-01-22 03:48:29 40 40" [ref=e214] [cursor=pointer]:
- cell "18" [ref=e215]
- cell "2026-01-22 03:48:29" [ref=e216]
- cell "40" [ref=e217]
- cell "40" [ref=e218]
- row "17 2026-01-22 09:34:17 40 40" [ref=e219] [cursor=pointer]:
- cell "17" [ref=e220]
- cell "2026-01-22 09:34:17" [ref=e221]
- cell "40" [ref=e222]
- cell "40" [ref=e223]
- row "16 2026-01-20 03:54:52 40 40" [ref=e224] [cursor=pointer]:
- cell "16" [ref=e225]
- cell "2026-01-20 03:54:52" [ref=e226]
- cell "40" [ref=e227]
- cell "40" [ref=e228]
- row "15 2026-01-19 00:20:12 40 40" [ref=e229] [cursor=pointer]:
- cell "15" [ref=e230]
- cell "2026-01-19 00:20:12" [ref=e231]
- cell "40" [ref=e232]
- cell "40" [ref=e233]
- row "14 2026-01-16 08:48:51 40 40" [ref=e234] [cursor=pointer]:
- cell "14" [ref=e235]
- cell "2026-01-16 08:48:51" [ref=e236]
- cell "40" [ref=e237]
- cell "40" [ref=e238]
- row "13 2026-01-10 01:51:12 40 40" [ref=e239] [cursor=pointer]:
- cell "13" [ref=e240]
- cell "2026-01-10 01:51:12" [ref=e241]
- cell "40" [ref=e242]
- cell "40" [ref=e243]
- row "12 2026-01-09 17:31:19 40 23" [ref=e244] [cursor=pointer]:
- cell "12" [ref=e245]
- cell "2026-01-09 17:31:19" [ref=e246]
- cell "40" [ref=e247]
- cell "23" [ref=e248]
- row "11 2026-01-09 17:26:05 40 40" [ref=e249] [cursor=pointer]:
- cell "11" [ref=e250]
- cell "2026-01-09 17:26:05" [ref=e251]
- cell "40" [ref=e252]
- cell "40" [ref=e253]
- row "10 2026-01-09 16:59:12 40 40" [ref=e254] [cursor=pointer]:
- cell "10" [ref=e255]
- cell "2026-01-09 16:59:12" [ref=e256]
- cell "40" [ref=e257]
- cell "40" [ref=e258]
- row "9 2026-01-09 16:52:01 40 40" [ref=e259] [cursor=pointer]:
- cell "9" [ref=e260]
- cell "2026-01-09 16:52:01" [ref=e261]
- cell "40" [ref=e262]
- cell "40" [ref=e263]
- row "8 2026-01-09 10:56:59 40 40" [ref=e264] [cursor=pointer]:
- cell "8" [ref=e265]
- cell "2026-01-09 10:56:59" [ref=e266]
- cell "40" [ref=e267]
- cell "40" [ref=e268]
- row "7 40 40" [ref=e269] [cursor=pointer]:
- cell "7" [ref=e270]
- cell [ref=e271]
- cell "40" [ref=e272]
- cell "40" [ref=e273]
- row "6 40 40" [ref=e274] [cursor=pointer]:
- cell "6" [ref=e275]
- cell [ref=e276]
- cell "40" [ref=e277]
- cell "40" [ref=e278]
- row "5 40 40" [ref=e279] [cursor=pointer]:
- cell "5" [ref=e280]
- cell [ref=e281]
- cell "40" [ref=e282]
- cell "40" [ref=e283]
- row "4 40 40" [ref=e284] [cursor=pointer]:
- cell "4" [ref=e285]
- cell [ref=e286]
- cell "40" [ref=e287]
- cell "40" [ref=e288]
- row "3 40 4" [ref=e289] [cursor=pointer]:
- cell "3" [ref=e290]
- cell [ref=e291]
- cell "40" [ref=e292]
- cell "4" [ref=e293]
- row "2 10 5" [ref=e294] [cursor=pointer]:
- cell "2" [ref=e295]
- cell [ref=e296]
- cell "10" [ref=e297]
- cell "5" [ref=e298]
- row "1 10 5" [ref=e299] [cursor=pointer]:
- cell "1" [ref=e300]
- cell [ref=e301]
- cell "10" [ref=e302]
- cell "5" [ref=e303]

View File

@ -1,139 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: h·lab
- generic [ref=e7]: YANALYST
- generic [ref=e9]: 분석
- navigation [ref=e10]:
- link "대시보드 01" [ref=e11] [cursor=pointer]:
- /url: "#"
- text: 대시보드
- generic [ref=e12]: "01"
- link "발굴 02" [ref=e13] [cursor=pointer]:
- /url: "#"
- text: 발굴
- generic [ref=e14]: "02"
- link "채널 03" [ref=e15] [cursor=pointer]:
- /url: "#"
- text: 채널
- generic [ref=e16]: "03"
- generic [ref=e17]: 파이프라인
- navigation [ref=e18]:
- link "수집함 04" [ref=e19] [cursor=pointer]:
- /url: "#"
- text: 수집함
- generic [ref=e20]: "04"
- link "칸반 보드 05" [ref=e21] [cursor=pointer]:
- /url: "#"
- text: 칸반 보드
- generic [ref=e22]: "05"
- link "발행 큐 06" [ref=e23] [cursor=pointer]:
- /url: "#"
- text: 발행 큐
- generic [ref=e24]: "06"
- generic [ref=e27]:
- generic [ref=e28]: hehiho
- generic [ref=e29]: 개인 작업공간
- main [ref=e30]:
- generic [ref=e31]:
- generic [ref=e32]:
- generic [ref=e33]: 수집 · 큐레이션 · 재가공 · 발행
- heading "대시보드" [level=1] [ref=e34]
- generic [ref=e35]:
- generic [ref=e36]: PIPELINE STATUS
- generic [ref=e37]: 2026 · 06 · 16
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e40]:
- generic [ref=e43]: 수집 영상 · TOTAL
- generic [ref=e44]: "623"
- generic [ref=e45]: 채널 623 — 검색 0 — Shorts 622
- generic [ref=e46]:
- generic [ref=e48]:
- generic [ref=e49]: 미검토
- generic [ref=e50]: 623 · 100%
- generic [ref=e54]:
- generic [ref=e55]: 검토중
- generic [ref=e56]: 0 · 0%
- generic [ref=e59]:
- generic [ref=e60]: 작업대상
- generic [ref=e61]: 0 · 0%
- generic [ref=e64]:
- generic [ref=e65]: 완료 · 발행
- generic [ref=e66]: 0 · 0%
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]:
- generic [ref=e71]: 미검토 / NEW
- generic [ref=e72]: 대기
- generic [ref=e73]: "623"
- generic [ref=e74]: 검토 대기
- generic [ref=e75]:
- generic [ref=e77]: 작업대상 / TARGET
- generic [ref=e78]: "0"
- generic [ref=e79]: 재가공 대상
- generic [ref=e80]:
- generic [ref=e82]: 완료 / DONE
- generic [ref=e83]: "0"
- generic [ref=e84]: 제외 0
- generic [ref=e85]:
- generic [ref=e87]: 발행 / PUBLISHED
- generic [ref=e88]: "0"
- generic [ref=e89]: 대기 0 · 작성 0
- generic [ref=e90]:
- generic [ref=e91]:
- generic [ref=e92]:
- heading "떡상 후보 — LEADERBOARD" [level=3] [ref=e93]
- generic [ref=e94]: 발굴 →
- generic [ref=e95]:
- generic [ref=e96]:
- generic [ref=e97]: "1"
- generic [ref=e99]:
- generic [ref=e100]: 悪い犬はいない でも悪い飼い主はいる
- generic [ref=e101]: SHORTS · 6D · 21,777,275
- generic [ref=e102]: 372×
- generic [ref=e103]:
- generic [ref=e104]: "2"
- generic [ref=e106]:
- generic [ref=e107]: 人生で一度は道徳に従わなきゃいけない瞬間
- generic [ref=e108]: SHORTS · 6D · 18,708,421
- generic [ref=e109]: 332×
- generic [ref=e110]:
- generic [ref=e111]: "3"
- generic [ref=e113]:
- generic [ref=e114]: 姫は俺たちが守る
- generic [ref=e115]: SHORTS · 6D · 16,759,054
- generic [ref=e116]: 289×
- generic [ref=e117]:
- generic [ref=e118]: "4"
- generic [ref=e120]:
- generic [ref=e121]: レーシング史上最高にロマンな事件
- generic [ref=e122]: SHORTS · 6D · 14,704,744
- generic [ref=e123]: 264×
- generic [ref=e124]:
- generic [ref=e125]: "5"
- generic [ref=e127]:
- generic [ref=e128]: パパの育児はいつも優しいわけじゃない
- generic [ref=e129]: SHORTS · 6D · 15,244,867
- generic [ref=e130]: 263×
- generic [ref=e131]:
- generic [ref=e132]:
- heading "출처 · 포맷" [level=3] [ref=e133]
- generic [ref=e134]: 수집함 →
- generic [ref=e135]:
- generic [ref=e136]:
- generic [ref=e137]: 채널 수집
- generic [ref=e138]: 623 · 100%
- generic [ref=e139]:
- generic [ref=e140]: 검색 수집
- generic [ref=e141]: 0 · 0%
- generic [ref=e142]:
- generic [ref=e143]: Shorts
- generic [ref=e144]: 622 · 100%
- generic [ref=e145]:
- generic [ref=e146]: 롱폼
- generic [ref=e147]: 1 · 0%
- generic [ref=e148]:
- heading "발행 현황" [level=3] [ref=e149]
- generic [ref=e150]: 발행 큐 →
- generic [ref=e152]: 발행 패키지 없음 — 재가공에서 생성

View File

@ -1,139 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: h·lab
- generic [ref=e7]: YANALYST
- generic [ref=e9]: 분석
- navigation [ref=e10]:
- link "대시보드 01" [ref=e11] [cursor=pointer]:
- /url: "#"
- text: 대시보드
- generic [ref=e12]: "01"
- link "발굴 02" [ref=e13] [cursor=pointer]:
- /url: "#"
- text: 발굴
- generic [ref=e14]: "02"
- link "채널 03" [ref=e15] [cursor=pointer]:
- /url: "#"
- text: 채널
- generic [ref=e16]: "03"
- generic [ref=e17]: 파이프라인
- navigation [ref=e18]:
- link "수집함 04" [ref=e19] [cursor=pointer]:
- /url: "#"
- text: 수집함
- generic [ref=e20]: "04"
- link "칸반 보드 05" [ref=e21] [cursor=pointer]:
- /url: "#"
- text: 칸반 보드
- generic [ref=e22]: "05"
- link "발행 큐 06" [ref=e23] [cursor=pointer]:
- /url: "#"
- text: 발행 큐
- generic [ref=e24]: "06"
- generic [ref=e27]:
- generic [ref=e28]: hehiho
- generic [ref=e29]: 개인 작업공간
- main [ref=e30]:
- generic [ref=e31]:
- generic [ref=e32]:
- generic [ref=e33]: 수집 · 큐레이션 · 재가공 · 발행
- heading "대시보드" [level=1] [ref=e34]
- generic [ref=e35]:
- generic [ref=e36]: PIPELINE STATUS
- generic [ref=e37]: 2026 · 06 · 16
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e40]:
- generic [ref=e43]: 수집 영상 · TOTAL
- generic [ref=e44]: "623"
- generic [ref=e45]: 채널 623 — 검색 0 — Shorts 622
- generic [ref=e46]:
- generic [ref=e48]:
- generic [ref=e49]: 미검토
- generic [ref=e50]: 623 · 100%
- generic [ref=e54]:
- generic [ref=e55]: 검토중
- generic [ref=e56]: 0 · 0%
- generic [ref=e59]:
- generic [ref=e60]: 작업대상
- generic [ref=e61]: 0 · 0%
- generic [ref=e64]:
- generic [ref=e65]: 완료 · 발행
- generic [ref=e66]: 0 · 0%
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]:
- generic [ref=e71]: 미검토 / NEW
- generic [ref=e72]: 대기
- generic [ref=e73]: "623"
- generic [ref=e74]: 검토 대기
- generic [ref=e75]:
- generic [ref=e77]: 작업대상 / TARGET
- generic [ref=e78]: "0"
- generic [ref=e79]: 재가공 대상
- generic [ref=e80]:
- generic [ref=e82]: 완료 / DONE
- generic [ref=e83]: "0"
- generic [ref=e84]: 제외 0
- generic [ref=e85]:
- generic [ref=e87]: 발행 / PUBLISHED
- generic [ref=e88]: "0"
- generic [ref=e89]: 대기 0 · 작성 0
- generic [ref=e90]:
- generic [ref=e91]:
- generic [ref=e92]:
- heading "떡상 후보 — LEADERBOARD" [level=3] [ref=e93]
- generic [ref=e94]: 발굴 →
- generic [ref=e95]:
- generic [ref=e96]:
- generic [ref=e97]: "1"
- generic [ref=e99]:
- generic [ref=e100]: 悪い犬はいない でも悪い飼い主はいる
- generic [ref=e101]: SHORTS · 6D · 21,777,275
- generic [ref=e102]: 372×
- generic [ref=e103]:
- generic [ref=e104]: "2"
- generic [ref=e106]:
- generic [ref=e107]: 人生で一度は道徳に従わなきゃいけない瞬間
- generic [ref=e108]: SHORTS · 6D · 18,708,421
- generic [ref=e109]: 332×
- generic [ref=e110]:
- generic [ref=e111]: "3"
- generic [ref=e113]:
- generic [ref=e114]: 姫は俺たちが守る
- generic [ref=e115]: SHORTS · 6D · 16,759,054
- generic [ref=e116]: 289×
- generic [ref=e117]:
- generic [ref=e118]: "4"
- generic [ref=e120]:
- generic [ref=e121]: レーシング史上最高にロマンな事件
- generic [ref=e122]: SHORTS · 6D · 14,704,744
- generic [ref=e123]: 264×
- generic [ref=e124]:
- generic [ref=e125]: "5"
- generic [ref=e127]:
- generic [ref=e128]: パパの育児はいつも優しいわけじゃない
- generic [ref=e129]: SHORTS · 6D · 15,244,867
- generic [ref=e130]: 263×
- generic [ref=e131]:
- generic [ref=e132]:
- heading "출처 · 포맷" [level=3] [ref=e133]
- generic [ref=e134]: 수집함 →
- generic [ref=e135]:
- generic [ref=e136]:
- generic [ref=e137]: 채널 수집
- generic [ref=e138]: 623 · 100%
- generic [ref=e139]:
- generic [ref=e140]: 검색 수집
- generic [ref=e141]: 0 · 0%
- generic [ref=e142]:
- generic [ref=e143]: Shorts
- generic [ref=e144]: 622 · 100%
- generic [ref=e145]:
- generic [ref=e146]: 롱폼
- generic [ref=e147]: 1 · 0%
- generic [ref=e148]:
- heading "발행 현황" [level=3] [ref=e149]
- generic [ref=e150]: 발행 큐 →
- generic [ref=e152]: 발행 패키지 없음 — 재가공에서 생성

View File

@ -1,170 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: H
- generic [ref=e6]:
- text: h-lab
- generic [ref=e7]: yanalyst
- generic [ref=e8]:
- generic [ref=e9]: 분석
- link "대시보드" [ref=e10] [cursor=pointer]:
- /url: "#"
- img [ref=e11]
- text: 대시보드
- link "발굴" [ref=e16] [cursor=pointer]:
- /url: "#"
- img [ref=e17]
- text: 발굴
- link "채널" [ref=e20] [cursor=pointer]:
- /url: "#"
- img [ref=e21]
- text: 채널
- generic [ref=e24]: 파이프라인
- link "수집함" [ref=e25] [cursor=pointer]:
- /url: "#"
- img [ref=e26]
- text: 수집함
- link "칸반 보드" [ref=e28] [cursor=pointer]:
- /url: "#"
- img [ref=e29]
- text: 칸반 보드
- link "재가공" [ref=e33] [cursor=pointer]:
- /url: "#"
- img [ref=e34]
- text: 재가공
- link "발행 큐" [ref=e36] [cursor=pointer]:
- /url: "#"
- img [ref=e37]
- text: 발행 큐
- generic [ref=e41]:
- generic [ref=e42]: hehiho
- text: 개인 작업공간
- main [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]:
- heading "대시보드" [level=1] [ref=e46]
- paragraph [ref=e47]: 수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황
- generic [ref=e48]:
- button "테마 전환" [ref=e49] [cursor=pointer]:
- img [ref=e50]
- button "새로고침" [ref=e52] [cursor=pointer]:
- img [ref=e53]
- text: 새로고침
- generic [ref=e56]:
- generic [ref=e57]:
- img [ref=e59]
- generic [ref=e62]: 수집 영상
- generic [ref=e63]: "623"
- generic [ref=e64]: 채널 623 · 검색 0
- generic [ref=e65]:
- img [ref=e67]
- generic [ref=e70]: 미검토 (NEW)
- generic [ref=e71]: "623"
- generic [ref=e72]: 검토 대기
- generic [ref=e74]:
- img [ref=e76]
- generic [ref=e79]: 작업대상
- generic [ref=e80]: "0"
- generic [ref=e81]: 재가공 대상
- generic [ref=e82]:
- img [ref=e84]
- generic [ref=e86]: 완료 (DONE)
- generic [ref=e87]: "0"
- generic [ref=e88]: 제외 0
- generic [ref=e89]:
- img [ref=e91]
- generic [ref=e93]: 발행완료
- generic [ref=e94]: "0"
- generic [ref=e95]: 대기 0 · 작성 0
- generic [ref=e96]:
- generic [ref=e97]:
- generic [ref=e98]:
- heading "파이프라인 현황" [level=3] [ref=e99]
- generic [ref=e100]: 총 623건 · 제외 0
- generic [ref=e101]:
- generic [ref=e102]: 미검토
- generic [ref=e105]: 623 · 100%
- generic [ref=e106]:
- generic [ref=e107]: 검토중
- generic [ref=e109]: 0 · 0%
- generic [ref=e110]:
- generic [ref=e111]: 작업대상
- generic [ref=e113]: 0 · 0%
- generic [ref=e114]:
- generic [ref=e115]: 완료
- generic [ref=e117]: 0 · 0%
- generic [ref=e118]:
- generic [ref=e119]: 발행완료
- generic [ref=e121]: 0 · 0%
- generic [ref=e122]:
- generic [ref=e123]:
- heading "발행 현황" [level=3] [ref=e124]
- link "발행 큐 →" [ref=e125] [cursor=pointer]:
- /url: "#"
- generic [ref=e126]:
- generic [ref=e127]: 작성중
- generic [ref=e128]: 0 · 0%
- generic [ref=e129]:
- generic [ref=e130]: 발행대기
- generic [ref=e131]: 0 · 0%
- generic [ref=e132]:
- generic [ref=e133]: 발행완료
- generic [ref=e134]: 0 · 0%
- generic [ref=e135]: 발행 패키지가 없습니다
- generic [ref=e136]:
- generic [ref=e137]:
- generic [ref=e138]:
- heading "🔥 떡상 후보 TOP" [level=3] [ref=e139]
- link "발굴 →" [ref=e140] [cursor=pointer]:
- /url: "#"
- generic [ref=e141]:
- generic [ref=e142]: "1"
- generic [ref=e144]:
- generic [ref=e145]: 悪い犬はいない でも悪い飼い主はいる…
- generic [ref=e146]: ショート · 6일 전 · 조회 21,777,275
- generic [ref=e147]: 378.1x
- generic [ref=e148]:
- generic [ref=e149]: "2"
- generic [ref=e151]:
- generic [ref=e152]: 人生で一度は道徳にぼわなきゃ…
- generic [ref=e153]: ショート · 6일 전 · 조회 18,202,665
- generic [ref=e154]: 314.3x
- generic [ref=e155]:
- generic [ref=e156]: "3"
- generic [ref=e158]:
- generic [ref=e159]: 姫は俺たちが守る
- generic [ref=e160]: ショート · 6일 전 · 조회 16,481,103
- generic [ref=e161]: 285.8x
- generic [ref=e162]:
- generic [ref=e163]: "4"
- generic [ref=e165]:
- generic [ref=e166]: パパの育児はいつも優しいわけで…
- generic [ref=e167]: ショート · 6일 전 · 조회 15,389,420
- generic [ref=e168]: 264.6x
- generic [ref=e169]:
- generic [ref=e170]: "5"
- generic [ref=e172]:
- generic [ref=e173]: レーシング史上最高にロマンな…
- generic [ref=e174]: ショート · 6일 전 · 조회 14,704,744
- generic [ref=e175]: 264.0x
- generic [ref=e176]:
- generic [ref=e177]:
- heading "수집 출처 & 포맷" [level=3] [ref=e178]
- link "수집함 →" [ref=e179] [cursor=pointer]:
- /url: "#"
- generic [ref=e180]:
- generic [ref=e181]: 채널 수집
- generic [ref=e182]: 623 · 100%
- generic [ref=e183]:
- generic [ref=e184]: 검색 수집
- generic [ref=e185]: 0 · 0%
- generic [ref=e186]:
- generic [ref=e187]: Shorts
- generic [ref=e188]: 622 · 100%
- generic [ref=e189]:
- generic [ref=e190]: 롱폼
- generic [ref=e191]: 1 · 0%
- heading "카테고리 분포" [level=3] [ref=e193]
- generic [ref=e194]:
- generic [ref=e195]: 미분류
- generic [ref=e196]: 623 · 100%

View File

@ -1,148 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: h·lab
- generic [ref=e7]: YANALYST
- generic [ref=e9]: 분석
- navigation [ref=e10]:
- link "대시보드 01" [ref=e11] [cursor=pointer]:
- /url: "#"
- text: 대시보드
- generic [ref=e12]: "01"
- link "발굴 02" [ref=e13] [cursor=pointer]:
- /url: "#"
- text: 발굴
- generic [ref=e14]: "02"
- link "채널 03" [ref=e15] [cursor=pointer]:
- /url: "#"
- text: 채널
- generic [ref=e16]: "03"
- generic [ref=e17]: 파이프라인
- navigation [ref=e18]:
- link "수집함 04" [ref=e19] [cursor=pointer]:
- /url: "#"
- text: 수집함
- generic [ref=e20]: "04"
- link "칸반 보드 05" [ref=e21] [cursor=pointer]:
- /url: "#"
- text: 칸반 보드
- generic [ref=e22]: "05"
- link "발행 큐 06" [ref=e23] [cursor=pointer]:
- /url: "#"
- text: 발행 큐
- generic [ref=e24]: "06"
- generic [ref=e27]:
- generic [ref=e28]: hehiho
- generic [ref=e29]: 개인 작업공간
- main [ref=e30]:
- generic [ref=e31]:
- generic [ref=e32]:
- generic [ref=e33]: 지금 할 일 중심
- heading "대시보드" [level=1] [ref=e34]
- generic [ref=e35]:
- generic [ref=e36]: 2026 · 06 · 16
- generic [ref=e37]: ● 동기화됨
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e40]:
- generic [ref=e41]:
- generic [ref=e42]: "623"
- generic [ref=e43]:
- generic [ref=e44]: 미검토 영상
- generic [ref=e45]: NEW · 검토 대기
- generic [ref=e46]:
- generic [ref=e47] [cursor=pointer]: 검토 시작 →
- generic [ref=e48] [cursor=pointer]: 칸반에서 보기
- generic [ref=e49]:
- generic [ref=e50]:
- generic [ref=e51]: "5"
- generic [ref=e52]:
- generic [ref=e53]: 떡상 후보
- generic [ref=e54]: 배율 ≥ 100× 발견
- generic [ref=e56] [cursor=pointer]: 발굴 열기 →
- generic [ref=e57]:
- generic [ref=e58]:
- generic [ref=e59]: "0"
- generic [ref=e60]:
- generic [ref=e61]: 재가공 대기
- generic [ref=e62]: TARGET
- generic [ref=e63]: 발굴에서 후보를 TARGET으로 보내세요 →
- generic [ref=e64]:
- generic [ref=e65]:
- generic [ref=e66]:
- heading "떡상 후보 — 바로 처리" [level=3] [ref=e67]
- generic [ref=e68]: 발굴 전체 →
- generic [ref=e69]:
- generic [ref=e70]:
- generic [ref=e71]: "1"
- generic [ref=e73]:
- generic [ref=e74]: 悪い犬はいない でも悪い飼い主はいる
- generic [ref=e75]: SHORTS · 6D · 21,777,275
- generic [ref=e76]: 372×
- generic [ref=e77]:
- generic [ref=e78] [cursor=pointer]: 재가공 →
- generic [ref=e79] [cursor=pointer]: 제외
- generic [ref=e80]:
- generic [ref=e81]: "2"
- generic [ref=e83]:
- generic [ref=e84]: 人生で一度は道徳に従わなきゃいけない
- generic [ref=e85]: SHORTS · 6D · 18,708,421
- generic [ref=e86]: 332×
- generic [ref=e87]:
- generic [ref=e88] [cursor=pointer]: 재가공 →
- generic [ref=e89] [cursor=pointer]: 제외
- generic [ref=e90]:
- generic [ref=e91]: "3"
- generic [ref=e93]:
- generic [ref=e94]: 姫は俺たちが守る
- generic [ref=e95]: SHORTS · 6D · 16,759,054
- generic [ref=e96]: 289×
- generic [ref=e97]:
- generic [ref=e98] [cursor=pointer]: 재가공 →
- generic [ref=e99] [cursor=pointer]: 제외
- generic [ref=e100]:
- generic [ref=e101]: "4"
- generic [ref=e103]:
- generic [ref=e104]: レーシング史上最高にロマンな事件
- generic [ref=e105]: SHORTS · 6D · 14,704,744
- generic [ref=e106]: 264×
- generic [ref=e107]:
- generic [ref=e108] [cursor=pointer]: 재가공 →
- generic [ref=e109] [cursor=pointer]: 제외
- generic [ref=e110]:
- generic [ref=e111]: "5"
- generic [ref=e113]:
- generic [ref=e114]: パパの育児はいつも優しいわけじゃない
- generic [ref=e115]: SHORTS · 6D · 15,244,867
- generic [ref=e116]: 263×
- generic [ref=e117]:
- generic [ref=e118] [cursor=pointer]: 재가공 →
- generic [ref=e119] [cursor=pointer]: 제외
- generic [ref=e120]:
- generic [ref=e121]:
- heading "파이프라인" [level=3] [ref=e122]
- generic [ref=e123]: 총 623
- generic [ref=e124]:
- generic [ref=e126]:
- generic [ref=e127]: 미검토
- generic [ref=e128]: 623 · 100%
- generic [ref=e132]:
- generic [ref=e133]: 검토중
- generic [ref=e134]: 0 · 0%
- generic [ref=e137]:
- generic [ref=e138]: 작업대상
- generic [ref=e139]: 0 · 0%
- generic [ref=e142]:
- generic [ref=e143]: 완료 · 발행
- generic [ref=e144]: 0 · 0%
- generic [ref=e146]:
- generic [ref=e147]: "병목: 미검토 623 — 검토가 0건"
- generic [ref=e148] [cursor=pointer]: 검토 시작
- heading "출처 · 포맷" [level=3] [ref=e150]
- generic [ref=e151]:
- generic [ref=e152]:
- generic [ref=e153]: 채널 수집
- generic [ref=e154]: 623 · 100%
- generic [ref=e155]:
- generic [ref=e156]: Shorts
- generic [ref=e157]: 622 · 100%

View File

@ -1,148 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: h·lab
- generic [ref=e7]: YANALYST
- generic [ref=e9]: 분석
- navigation [ref=e10]:
- link "대시보드 01" [ref=e11] [cursor=pointer]:
- /url: "#"
- text: 대시보드
- generic [ref=e12]: "01"
- link "발굴 02" [ref=e13] [cursor=pointer]:
- /url: "#"
- text: 발굴
- generic [ref=e14]: "02"
- link "채널 03" [ref=e15] [cursor=pointer]:
- /url: "#"
- text: 채널
- generic [ref=e16]: "03"
- generic [ref=e17]: 파이프라인
- navigation [ref=e18]:
- link "수집함 04" [ref=e19] [cursor=pointer]:
- /url: "#"
- text: 수집함
- generic [ref=e20]: "04"
- link "칸반 보드 05" [ref=e21] [cursor=pointer]:
- /url: "#"
- text: 칸반 보드
- generic [ref=e22]: "05"
- link "발행 큐 06" [ref=e23] [cursor=pointer]:
- /url: "#"
- text: 발행 큐
- generic [ref=e24]: "06"
- generic [ref=e27]:
- generic [ref=e28]: hehiho
- generic [ref=e29]: 개인 작업공간
- main [ref=e30]:
- generic [ref=e31]:
- generic [ref=e32]:
- generic [ref=e33]: 지금 할 일 중심
- heading "대시보드" [level=1] [ref=e34]
- generic [ref=e35]:
- generic [ref=e36]: 2026 · 06 · 16
- generic [ref=e37]: ● 동기화됨
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e40]:
- generic [ref=e41]:
- generic [ref=e42]: "623"
- generic [ref=e43]:
- generic [ref=e44]: 미검토 영상
- generic [ref=e45]: NEW · 검토 대기
- generic [ref=e46]:
- generic [ref=e47] [cursor=pointer]: 검토 시작 →
- generic [ref=e48] [cursor=pointer]: 칸반에서 보기
- generic [ref=e49]:
- generic [ref=e50]:
- generic [ref=e51]: "5"
- generic [ref=e52]:
- generic [ref=e53]: 떡상 후보
- generic [ref=e54]: 배율 ≥ 100× 발견
- generic [ref=e56] [cursor=pointer]: 발굴 열기 →
- generic [ref=e57]:
- generic [ref=e58]:
- generic [ref=e59]: "0"
- generic [ref=e60]:
- generic [ref=e61]: 재가공 대기
- generic [ref=e62]: TARGET
- generic [ref=e63]: 발굴에서 후보를 TARGET으로 보내세요 →
- generic [ref=e64]:
- generic [ref=e65]:
- generic [ref=e66]:
- heading "떡상 후보 — 바로 처리" [level=3] [ref=e67]
- generic [ref=e68]: 발굴 전체 →
- generic [ref=e69]:
- generic [ref=e70]:
- generic [ref=e71]: "1"
- generic [ref=e73]:
- generic [ref=e74]: 悪い犬はいない でも悪い飼い主はいる
- generic [ref=e75]: SHORTS · 6D · 21,777,275
- generic [ref=e76]: 372×
- generic [ref=e77]:
- generic [ref=e78] [cursor=pointer]: 재가공 →
- generic [ref=e79] [cursor=pointer]: 제외
- generic [ref=e80]:
- generic [ref=e81]: "2"
- generic [ref=e83]:
- generic [ref=e84]: 人生で一度は道徳に従わなきゃいけない
- generic [ref=e85]: SHORTS · 6D · 18,708,421
- generic [ref=e86]: 332×
- generic [ref=e87]:
- generic [ref=e88] [cursor=pointer]: 재가공 →
- generic [ref=e89] [cursor=pointer]: 제외
- generic [ref=e90]:
- generic [ref=e91]: "3"
- generic [ref=e93]:
- generic [ref=e94]: 姫は俺たちが守る
- generic [ref=e95]: SHORTS · 6D · 16,759,054
- generic [ref=e96]: 289×
- generic [ref=e97]:
- generic [ref=e98] [cursor=pointer]: 재가공 →
- generic [ref=e99] [cursor=pointer]: 제외
- generic [ref=e100]:
- generic [ref=e101]: "4"
- generic [ref=e103]:
- generic [ref=e104]: レーシング史上最高にロマンな事件
- generic [ref=e105]: SHORTS · 6D · 14,704,744
- generic [ref=e106]: 264×
- generic [ref=e107]:
- generic [ref=e108] [cursor=pointer]: 재가공 →
- generic [ref=e109] [cursor=pointer]: 제외
- generic [ref=e110]:
- generic [ref=e111]: "5"
- generic [ref=e113]:
- generic [ref=e114]: パパの育児はいつも優しいわけじゃない
- generic [ref=e115]: SHORTS · 6D · 15,244,867
- generic [ref=e116]: 263×
- generic [ref=e117]:
- generic [ref=e118] [cursor=pointer]: 재가공 →
- generic [ref=e119] [cursor=pointer]: 제외
- generic [ref=e120]:
- generic [ref=e121]:
- heading "파이프라인" [level=3] [ref=e122]
- generic [ref=e123]: 총 623
- generic [ref=e124]:
- generic [ref=e126]:
- generic [ref=e127]: 미검토
- generic [ref=e128]: 623 · 100%
- generic [ref=e132]:
- generic [ref=e133]: 검토중
- generic [ref=e134]: 0 · 0%
- generic [ref=e137]:
- generic [ref=e138]: 작업대상
- generic [ref=e139]: 0 · 0%
- generic [ref=e142]:
- generic [ref=e143]: 완료 · 발행
- generic [ref=e144]: 0 · 0%
- generic [ref=e146]:
- generic [ref=e147]: "병목: 미검토 623 — 검토가 0건"
- generic [ref=e148] [cursor=pointer]: 검토 시작
- heading "출처 · 포맷" [level=3] [ref=e150]
- generic [ref=e151]:
- generic [ref=e152]:
- generic [ref=e153]: 채널 수집
- generic [ref=e154]: 623 · 100%
- generic [ref=e155]:
- generic [ref=e156]: Shorts
- generic [ref=e157]: 622 · 100%

View File

@ -1,110 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85]: 카테고리
- generic [ref=e87]: 카테고리가 없습니다. + 로 추가하세요.
- button "추가" [ref=e88] [cursor=pointer]:
- img [ref=e89]
- text: 추가
- generic [ref=e91]:
- generic [ref=e92] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e93] [cursor=pointer]: Shorts
- generic [ref=e94] [cursor=pointer]: ⭐ 북마크
- generic [ref=e96]:
- text: 상태
- combobox "상태" [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- text: 카테고리
- combobox "카테고리" [ref=e99]:
- option "전체" [selected]
- generic [ref=e100]:
- text: 출처
- combobox "출처" [ref=e101]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e102]:
- text: 정렬
- combobox "정렬" [ref=e103]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e105] [cursor=pointer]:
- img [ref=e106]
- table [ref=e112]:
- rowgroup [ref=e113]:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e114]:
- columnheader "영상" [ref=e115]
- columnheader "구독자" [ref=e116]
- columnheader "조회수" [ref=e117]
- columnheader "시간당" [ref=e118]
- columnheader "배율" [ref=e119]
- columnheader "상태" [ref=e120]
- columnheader "카테고리" [ref=e121]
- columnheader "관리" [ref=e122]
- rowgroup [ref=e123]:
- row "로딩 중..." [ref=e124]:
- cell "로딩 중..." [ref=e125]

View File

@ -1,110 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "수집함" [level=1] [ref=e76]
- paragraph [ref=e77]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- generic [ref=e84]:
- generic [ref=e85]: 카테고리
- generic [ref=e87]: 카테고리가 없습니다. + 로 추가하세요.
- button "추가" [ref=e88] [cursor=pointer]:
- img [ref=e89]
- text: 추가
- generic [ref=e91]:
- generic [ref=e92] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e93] [cursor=pointer]: Shorts
- generic [ref=e94] [cursor=pointer]: ⭐ 북마크
- generic [ref=e96]:
- text: 상태
- combobox "상태" [ref=e97]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e98]:
- text: 카테고리
- combobox "카테고리" [ref=e99]:
- option "전체" [selected]
- generic [ref=e100]:
- text: 출처
- combobox "출처" [ref=e101]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e102]:
- text: 정렬
- combobox "정렬" [ref=e103]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e105] [cursor=pointer]:
- img [ref=e106]
- table [ref=e112]:
- rowgroup [ref=e113]:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e114]:
- columnheader "영상" [ref=e115]
- columnheader "구독자" [ref=e116]
- columnheader "조회수" [ref=e117]
- columnheader "시간당" [ref=e118]
- columnheader "배율" [ref=e119]
- columnheader "상태" [ref=e120]
- columnheader "카테고리" [ref=e121]
- columnheader "관리" [ref=e122]
- rowgroup [ref=e123]:
- row "로딩 중..." [ref=e124]:
- cell "로딩 중..." [ref=e125]

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]:
- generic [ref=e7]: H
- generic [ref=e8]:
- generic [ref=e9]: h-lab
- generic [ref=e10]: yanalyst
- button [ref=e11] [cursor=pointer]:
- img [ref=e12]
- navigation [ref=e14]:
- generic [ref=e15]: 분석
- link "대시보드" [ref=e16] [cursor=pointer]:
- /url: /
- img [ref=e17]
- generic [ref=e22]: 대시보드
- link "발굴" [ref=e23] [cursor=pointer]:
- /url: /discover
- img [ref=e24]
- generic [ref=e31]: 발굴
- link "채널" [ref=e32] [cursor=pointer]:
- /url: /channels
- img [ref=e33]
- generic [ref=e38]: 채널
- generic [ref=e39]: 파이프라인
- link "수집함" [ref=e40] [cursor=pointer]:
- /url: /collection
- img [ref=e41]
- generic [ref=e43]: 수집함
- link "칸반 보드" [ref=e44] [cursor=pointer]:
- /url: /board
- img [ref=e45]
- generic [ref=e47]: 칸반 보드
- link "발행 큐" [ref=e48] [cursor=pointer]:
- /url: /publish
- img [ref=e49]
- generic [ref=e52]: 발행 큐
- generic [ref=e53]: 제작
- link "프로덕션" [ref=e54] [cursor=pointer]:
- /url: /production
- img [ref=e55]
- generic [ref=e60]: 프로덕션
- generic [ref=e61]:
- button "테마 전환" [ref=e62] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e66]: 테마 전환
- generic [ref=e69]:
- generic [ref=e70]: hehiho
- generic [ref=e71]: 개인 작업공간
- main [ref=e72]:
- generic [ref=e74]:
- generic [ref=e75]:
- heading "칸반 보드" [level=1] [ref=e76]
- paragraph [ref=e77]: 카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.
- generic [ref=e78]:
- button "사용법" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- text: 사용법
- button "새로고침" [ref=e83] [cursor=pointer]:
- img [ref=e84]
- text: 새로고침

File diff suppressed because it is too large Load Diff

View File

@ -1,89 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e54]
- generic [ref=e56]:
- generic [ref=e57]:
- generic [ref=e58]:
- generic [ref=e59]: 지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행
- heading "대시보드" [level=1] [ref=e60]
- generic [ref=e61]:
- generic [ref=e62]: PIPELINE STATUS
- generic [ref=e63]: 2026 · 06 · 17
- button "새로고침" [ref=e64] [cursor=pointer]:
- img [ref=e65]
- text: 새로고침
- generic [ref=e70]:
- generic [ref=e71]:
- link " 미검토 영상 NEW · 검토 대기 검토 시작 →" [ref=e72] [cursor=pointer]:
- /url: /board
- generic [ref=e73]:
- generic [ref=e74]:
- generic [ref=e75]:
- generic [ref=e76]: 미검토 영상
- generic [ref=e77]: NEW · 검토 대기
- generic [ref=e79]: 검토 시작 →
- link " 떡상 후보 배율 상위 발굴 열기 →" [ref=e80] [cursor=pointer]:
- /url: /discover
- generic [ref=e81]:
- generic [ref=e82]:
- generic [ref=e83]:
- generic [ref=e84]: 떡상 후보
- generic [ref=e85]: 배율 상위
- generic [ref=e87]: 발굴 열기 →
- link " 재가공 대기 TARGET 발굴에서 후보를 TARGET으로 보내세요 →" [ref=e88] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e89]:
- generic [ref=e90]:
- generic [ref=e91]:
- generic [ref=e92]: 재가공 대기
- generic [ref=e93]: TARGET
- generic [ref=e94]: 발굴에서 후보를 TARGET으로 보내세요 →
- generic [ref=e95]:
- generic [ref=e96]:
- generic [ref=e97]:
- heading "떡상 후보 — 바로 처리" [level=3] [ref=e98]
- link "발굴 전체 →" [ref=e99] [cursor=pointer]:
- /url: /discover
- generic [ref=e101]: 불러오는 중…
- generic [ref=e102]:
- generic [ref=e103]:
- heading "파이프라인" [level=3] [ref=e104]
- generic [ref=e105]: 총 —
- generic [ref=e107]: 불러오는 중…
- generic [ref=e108]:
- heading "출처 · 포맷" [level=3] [ref=e109]
- link "수집함 →" [ref=e110] [cursor=pointer]:
- /url: /collection
- generic [ref=e112]:
- heading "발행 현황" [level=3] [ref=e113]
- link "발행 큐 →" [ref=e114] [cursor=pointer]:
- /url: /publish

View File

@ -1,179 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e116]
- navigation [ref=e11]:
- link:
- /url: /
- img [ref=e118] [cursor=pointer]
- link:
- /url: /discover
- img [ref=e123] [cursor=pointer]
- link:
- /url: /channels
- img [ref=e130] [cursor=pointer]
- link:
- /url: /collection
- img [ref=e135] [cursor=pointer]
- link:
- /url: /board
- img [ref=e137] [cursor=pointer]
- link:
- /url: /publish
- img [ref=e139] [cursor=pointer]
- link:
- /url: /production
- img [ref=e142] [cursor=pointer]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e147]
- generic [ref=e56]:
- generic [ref=e57]:
- generic [ref=e58]:
- generic [ref=e59]: 지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행
- heading "대시보드" [level=1] [ref=e60]
- generic [ref=e61]:
- generic [ref=e62]: PIPELINE STATUS
- generic [ref=e63]: 2026 · 06 · 17
- button "새로고침" [ref=e64] [cursor=pointer]:
- img [ref=e148]
- text: 새로고침
- generic [ref=e70]:
- generic [ref=e71]:
- link "686 미검토 영상 NEW · 검토 대기 검토 시작 →" [ref=e153] [cursor=pointer]:
- /url: /board
- generic [ref=e73]:
- generic [ref=e74]: "686"
- generic [ref=e75]:
- generic [ref=e76]: 미검토 영상
- generic [ref=e77]: NEW · 검토 대기
- generic [ref=e79]: 검토 시작 →
- link "5 떡상 후보 배율 상위 · 최고 365× 발굴 열기 →" [ref=e154] [cursor=pointer]:
- /url: /discover
- generic [ref=e81]:
- generic [ref=e82]: "5"
- generic [ref=e83]:
- generic [ref=e84]: 떡상 후보
- generic [ref=e85]: 배율 상위 · 최고 365×
- generic [ref=e87]: 발굴 열기 →
- link "0 재가공 대기 TARGET 발굴에서 후보를 TARGET으로 보내세요 →" [ref=e155] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e89]:
- generic [ref=e90]: "0"
- generic [ref=e91]:
- generic [ref=e92]: 재가공 대기
- generic [ref=e93]: TARGET
- generic [ref=e94]: 발굴에서 후보를 TARGET으로 보내세요 →
- generic [ref=e95]:
- generic [ref=e96]:
- generic [ref=e97]:
- heading "떡상 후보 — 바로 처리" [level=3] [ref=e98]
- link "발굴 전체 →" [ref=e99] [cursor=pointer]:
- /url: /discover
- generic [ref=e100]:
- generic [ref=e156]:
- generic [ref=e157]: "1"
- img "미리보기" [ref=e158] [cursor=pointer]
- generic:
- link "悪い犬はいない でも悪い飼い主はいる" [ref=e159] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=OpmSTCxPzGM
- generic: SHORTS · ショーツ見る日 · 21,850,645
- generic [ref=e160]: 365×
- generic [ref=e161]:
- link "재가공 →" [ref=e162] [cursor=pointer]:
- /url: /rework/560
- button "제외" [ref=e163] [cursor=pointer]
- generic [ref=e164]:
- generic [ref=e165]: "2"
- img "미리보기" [ref=e166] [cursor=pointer]
- generic:
- link "人生で一度は直感に従わなきゃいけない瞬間がある" [ref=e167] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=rtAgbgSgHgc
- generic: SHORTS · ショーツ見る日 · 19,599,447
- generic [ref=e168]: 327×
- generic [ref=e169]:
- link "재가공 →" [ref=e170] [cursor=pointer]:
- /url: /rework/470
- button "제외" [ref=e171] [cursor=pointer]
- generic [ref=e172]:
- generic [ref=e173]: "3"
- img "미리보기" [ref=e174] [cursor=pointer]
- generic:
- link "姫は俺たちが守る" [ref=e175] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=4HHpI32LnZg
- generic: SHORTS · ショーツ見る日 · 17,128,231
- generic [ref=e176]: 286×
- generic [ref=e177]:
- link "재가공 →" [ref=e178] [cursor=pointer]:
- /url: /rework/553
- button "제외" [ref=e179] [cursor=pointer]
- generic [ref=e180]:
- generic [ref=e181]: "4"
- img "미리보기" [ref=e182] [cursor=pointer]
- generic:
- link "レーシング史上最高にロマンな事件🏎️👍" [ref=e183] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=ax37_8CAw_k
- generic: SHORTS · ショーツ見る日 · 14,704,744
- generic [ref=e184]: 264×
- generic [ref=e185]:
- link "재가공 →" [ref=e186] [cursor=pointer]:
- /url: /rework/599
- button "제외" [ref=e187] [cursor=pointer]
- generic [ref=e188]:
- generic [ref=e189]: "5"
- img "미리보기" [ref=e190] [cursor=pointer]
- generic:
- link "パパの育児はいつも優しいわけじゃないけど" [ref=e191] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=t0qYgTbQ6Fg
- generic: SHORTS · ショーツ見る日 · 15,250,832
- generic [ref=e192]: 255×
- generic [ref=e193]:
- link "재가공 →" [ref=e194] [cursor=pointer]:
- /url: /rework/504
- button "제외" [ref=e195] [cursor=pointer]
- generic [ref=e102]:
- generic [ref=e103]:
- heading "파이프라인" [level=3] [ref=e104]
- generic [ref=e105]: 총 686
- generic [ref=e106]:
- generic [ref=e197]:
- generic [ref=e198]: 미검토
- generic [ref=e199]: 686 · 100%
- generic [ref=e203]:
- generic [ref=e204]: 검토중
- generic [ref=e205]: 0 · 0%
- generic [ref=e208]:
- generic [ref=e209]: 작업대상
- generic [ref=e210]: 0 · 0%
- generic [ref=e213]:
- generic [ref=e214]: 완료
- generic [ref=e215]: 0 · 0%
- generic [ref=e218]:
- generic [ref=e219]: 발행완료
- generic [ref=e220]: 0 · 0%
- generic [ref=e222]:
- generic [ref=e223]: "병목: 미검토 686 — 다음 단계로 옮겨보세요"
- link "칸반 보드" [ref=e224] [cursor=pointer]:
- /url: /board
- generic [ref=e108]:
- heading "출처 · 포맷" [level=3] [ref=e109]
- link "수집함 →" [ref=e110] [cursor=pointer]:
- /url: /collection
- generic [ref=e111]:
- generic [ref=e225]:
- generic [ref=e226]: 채널 수집
- generic [ref=e227]: 686 · 100%
- generic [ref=e228]:
- generic [ref=e229]: 검색 수집
- generic [ref=e230]: 0 · 0%
- generic [ref=e231]:
- generic [ref=e232]: Shorts
- generic [ref=e233]: 685 · 100%
- generic [ref=e112]:
- heading "발행 현황" [level=3] [ref=e113]
- link "발행 큐 →" [ref=e114] [cursor=pointer]:
- /url: /publish
- generic [ref=e234]: 발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.

View File

@ -1,86 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e54]
- generic [ref=e56]:
- generic [ref=e57]:
- heading "대시보드" [level=1] [ref=e59]
- generic [ref=e60]:
- generic [ref=e61]: 2026 · 06 · 17
- button "새로고침" [ref=e62] [cursor=pointer]:
- img [ref=e63]
- text: 새로고침
- generic [ref=e68]:
- generic [ref=e69]:
- link " 미검토 영상 NEW · 검토 대기 검토 시작 →" [ref=e70] [cursor=pointer]:
- /url: /board
- generic [ref=e71]:
- generic [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]: 미검토 영상
- generic [ref=e75]: NEW · 검토 대기
- generic [ref=e77]: 검토 시작 →
- link " 떡상 후보 배율 상위 발굴 열기 →" [ref=e78] [cursor=pointer]:
- /url: /discover
- generic [ref=e79]:
- generic [ref=e80]:
- generic [ref=e81]:
- generic [ref=e82]: 떡상 후보
- generic [ref=e83]: 배율 상위
- generic [ref=e85]: 발굴 열기 →
- link " 재가공 대기 TARGET 발굴에서 후보를 TARGET으로 보내세요 →" [ref=e86] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e87]:
- generic [ref=e88]:
- generic [ref=e89]:
- generic [ref=e90]: 재가공 대기
- generic [ref=e91]: TARGET
- generic [ref=e92]: 발굴에서 후보를 TARGET으로 보내세요 →
- generic [ref=e93]:
- generic [ref=e94]:
- generic [ref=e95]:
- heading "떡상 후보 — 바로 처리" [level=3] [ref=e96]
- link "발굴 전체 →" [ref=e97] [cursor=pointer]:
- /url: /discover
- generic [ref=e99]: 불러오는 중…
- generic [ref=e100]:
- generic [ref=e101]:
- heading "파이프라인" [level=3] [ref=e102]
- generic [ref=e103]: 총 —
- generic [ref=e105]: 불러오는 중…
- generic [ref=e106]:
- heading "출처 · 포맷" [level=3] [ref=e107]
- link "수집함 →" [ref=e108] [cursor=pointer]:
- /url: /collection
- generic [ref=e110]:
- heading "발행 현황" [level=3] [ref=e111]
- link "발행 큐 →" [ref=e112] [cursor=pointer]:
- /url: /publish

View File

@ -1,176 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e114]
- navigation [ref=e11]:
- link:
- /url: /
- img [ref=e116] [cursor=pointer]
- link:
- /url: /discover
- img [ref=e121] [cursor=pointer]
- link:
- /url: /channels
- img [ref=e128] [cursor=pointer]
- link:
- /url: /collection
- img [ref=e133] [cursor=pointer]
- link:
- /url: /board
- img [ref=e135] [cursor=pointer]
- link:
- /url: /publish
- img [ref=e137] [cursor=pointer]
- link:
- /url: /production
- img [ref=e140] [cursor=pointer]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e145]
- generic [ref=e56]:
- generic [ref=e57]:
- heading "대시보드" [level=1] [ref=e59]
- generic [ref=e60]:
- generic [ref=e61]: 2026 · 06 · 17
- button "새로고침" [ref=e62] [cursor=pointer]:
- img [ref=e146]
- text: 새로고침
- generic [ref=e68]:
- generic [ref=e69]:
- link "686 미검토 영상 NEW · 검토 대기 검토 시작 →" [ref=e151] [cursor=pointer]:
- /url: /board
- generic [ref=e71]:
- generic [ref=e72]: "686"
- generic [ref=e73]:
- generic [ref=e74]: 미검토 영상
- generic [ref=e75]: NEW · 검토 대기
- generic [ref=e77]: 검토 시작 →
- link "5 떡상 후보 배율 상위 · 최고 365× 발굴 열기 →" [ref=e152] [cursor=pointer]:
- /url: /discover
- generic [ref=e79]:
- generic [ref=e80]: "5"
- generic [ref=e81]:
- generic [ref=e82]: 떡상 후보
- generic [ref=e83]: 배율 상위 · 최고 365×
- generic [ref=e85]: 발굴 열기 →
- link "0 재가공 대기 TARGET 발굴에서 후보를 TARGET으로 보내세요 →" [ref=e153] [cursor=pointer]:
- /url: /collection?status=TARGET
- generic [ref=e87]:
- generic [ref=e88]: "0"
- generic [ref=e89]:
- generic [ref=e90]: 재가공 대기
- generic [ref=e91]: TARGET
- generic [ref=e92]: 발굴에서 후보를 TARGET으로 보내세요 →
- generic [ref=e93]:
- generic [ref=e94]:
- generic [ref=e95]:
- heading "떡상 후보 — 바로 처리" [level=3] [ref=e96]
- link "발굴 전체 →" [ref=e97] [cursor=pointer]:
- /url: /discover
- generic [ref=e98]:
- generic [ref=e154]:
- generic [ref=e155]: "1"
- img "미리보기" [ref=e156] [cursor=pointer]
- generic [ref=e157]:
- link "悪い犬はいない でも悪い飼い主はいる" [ref=e159] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=OpmSTCxPzGM
- generic [ref=e160]: SHORTS · ショーツ見る日 · 21,850,645
- generic [ref=e161]: 365×
- generic [ref=e162]:
- link "재가공 →" [ref=e163] [cursor=pointer]:
- /url: /rework/560
- button "제외" [ref=e164] [cursor=pointer]
- generic [ref=e165]:
- generic [ref=e166]: "2"
- img "미리보기" [ref=e167] [cursor=pointer]
- generic [ref=e168]:
- link "人生で一度は直感に従わなきゃいけない瞬間がある" [ref=e170] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=rtAgbgSgHgc
- generic [ref=e171]: SHORTS · ショーツ見る日 · 19,599,447
- generic [ref=e172]: 327×
- generic [ref=e173]:
- link "재가공 →" [ref=e174] [cursor=pointer]:
- /url: /rework/470
- button "제외" [ref=e175] [cursor=pointer]
- generic [ref=e176]:
- generic [ref=e177]: "3"
- img "미리보기" [ref=e178] [cursor=pointer]
- generic [ref=e179]:
- link "姫は俺たちが守る" [ref=e181] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=4HHpI32LnZg
- generic [ref=e182]: SHORTS · ショーツ見る日 · 17,128,231
- generic [ref=e183]: 286×
- generic [ref=e184]:
- link "재가공 →" [ref=e185] [cursor=pointer]:
- /url: /rework/553
- button "제외" [ref=e186] [cursor=pointer]
- generic [ref=e187]:
- generic [ref=e188]: "4"
- img "미리보기" [ref=e189] [cursor=pointer]
- generic [ref=e190]:
- link "レーシング史上最高にロマンな事件🏎️👍" [ref=e192] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=ax37_8CAw_k
- generic [ref=e193]: SHORTS · ショーツ見る日 · 14,704,744
- generic [ref=e194]: 264×
- generic [ref=e195]:
- link "재가공 →" [ref=e196] [cursor=pointer]:
- /url: /rework/599
- button "제외" [ref=e197] [cursor=pointer]
- generic [ref=e198]:
- generic [ref=e199]: "5"
- img "미리보기" [ref=e200] [cursor=pointer]
- generic [ref=e201]:
- link "パパの育児はいつも優しいわけじゃないけど" [ref=e203] [cursor=pointer]:
- /url: https://www.youtube.com/watch?v=t0qYgTbQ6Fg
- generic [ref=e204]: SHORTS · ショーツ見る日 · 15,250,832
- generic [ref=e205]: 255×
- generic [ref=e206]:
- link "재가공 →" [ref=e207] [cursor=pointer]:
- /url: /rework/504
- button "제외" [ref=e208] [cursor=pointer]
- generic [ref=e100]:
- generic [ref=e101]:
- heading "파이프라인" [level=3] [ref=e102]
- generic [ref=e103]: 총 686
- generic [ref=e104]:
- generic [ref=e210]:
- generic [ref=e211]: 미검토
- generic [ref=e212]: 686 · 100%
- generic [ref=e216]:
- generic [ref=e217]: 검토중
- generic [ref=e218]: 0 · 0%
- generic [ref=e221]:
- generic [ref=e222]: 작업대상
- generic [ref=e223]: 0 · 0%
- generic [ref=e226]:
- generic [ref=e227]: 완료
- generic [ref=e228]: 0 · 0%
- generic [ref=e231]:
- generic [ref=e232]: 발행완료
- generic [ref=e233]: 0 · 0%
- generic [ref=e235]:
- generic [ref=e236]: "병목: 미검토 686 — 다음 단계로 옮겨보세요"
- link "칸반 보드" [ref=e237] [cursor=pointer]:
- /url: /board
- generic [ref=e106]:
- heading "출처 · 포맷" [level=3] [ref=e107]
- link "수집함 →" [ref=e108] [cursor=pointer]:
- /url: /collection
- generic [ref=e109]:
- generic [ref=e238]:
- generic [ref=e239]: 채널 수집
- generic [ref=e240]: 686 · 100%
- generic [ref=e241]:
- generic [ref=e242]: 검색 수집
- generic [ref=e243]: 0 · 0%
- generic [ref=e244]:
- generic [ref=e245]: Shorts
- generic [ref=e246]: 685 · 100%
- generic [ref=e110]:
- heading "발행 현황" [level=3] [ref=e111]
- link "발행 큐 →" [ref=e112] [cursor=pointer]:
- /url: /publish
- generic [ref=e247]: 발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.

View File

@ -1,92 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e54]
- generic [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]:
- heading "수집함" [level=1] [ref=e58]
- paragraph [ref=e59]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e61] [cursor=pointer]:
- img [ref=e62]
- text: 사용법
- generic [ref=e66]:
- generic [ref=e67]: 카테고리
- generic [ref=e69]: 로딩...
- button "추가" [ref=e70] [cursor=pointer]:
- img [ref=e71]
- text: 추가
- generic [ref=e73]:
- generic [ref=e74] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e75] [cursor=pointer]: Shorts
- generic [ref=e76] [cursor=pointer]: ⭐ 북마크
- generic [ref=e78]:
- text: 상태
- combobox "상태" [ref=e79]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e80]:
- text: 카테고리
- combobox "카테고리" [ref=e81]:
- option "전체" [selected]
- generic [ref=e82]:
- text: 출처
- combobox "출처" [ref=e83]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e84]:
- text: 정렬
- combobox "정렬" [ref=e85]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e87] [cursor=pointer]:
- img [ref=e88]
- table [ref=e94]:
- rowgroup:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e95]:
- columnheader "영상" [ref=e96]
- columnheader "구독자" [ref=e97]
- columnheader "조회수" [ref=e98]
- columnheader "시간당" [ref=e99]
- columnheader "배율" [ref=e100]
- columnheader "상태" [ref=e101]
- columnheader "카테고리" [ref=e102]
- columnheader "관리" [ref=e103]
- rowgroup [ref=e104]:
- row "로딩 중..." [ref=e105]:
- cell "로딩 중..." [ref=e106]

File diff suppressed because it is too large Load Diff

View File

@ -1,92 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e54]
- generic [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]:
- heading "수집함" [level=1] [ref=e58]
- paragraph [ref=e59]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e61] [cursor=pointer]:
- img [ref=e62]
- text: 사용법
- generic [ref=e66]:
- generic [ref=e67]: 카테고리
- generic [ref=e69]: 로딩...
- button "추가" [ref=e70] [cursor=pointer]:
- img [ref=e71]
- text: 추가
- generic [ref=e73]:
- generic [ref=e74] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e75] [cursor=pointer]: Shorts
- generic [ref=e76] [cursor=pointer]: ⭐ 북마크
- generic [ref=e78]:
- text: 상태
- combobox "상태" [ref=e79]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e80]:
- text: 카테고리
- combobox "카테고리" [ref=e81]:
- option "전체" [selected]
- generic [ref=e82]:
- text: 출처
- combobox "출처" [ref=e83]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e84]:
- text: 정렬
- combobox "정렬" [ref=e85]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e87] [cursor=pointer]:
- img [ref=e88]
- table [ref=e94]:
- rowgroup:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e95]:
- columnheader "영상" [ref=e96]
- columnheader "구독자" [ref=e97]
- columnheader "조회수" [ref=e98]
- columnheader "시간당" [ref=e99]
- columnheader "배율" [ref=e100]
- columnheader "상태" [ref=e101]
- columnheader "카테고리" [ref=e102]
- columnheader "관리" [ref=e103]
- rowgroup [ref=e104]:
- row "로딩 중..." [ref=e105]:
- cell "로딩 중..." [ref=e106]

File diff suppressed because it is too large Load Diff

View File

@ -1,90 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- generic [ref=e55]:
- heading "수집함" [level=1] [ref=e56]
- paragraph [ref=e57]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e59] [cursor=pointer]:
- img [ref=e60]
- text: 사용법
- generic [ref=e64]:
- generic [ref=e65]: 카테고리
- generic [ref=e67]: 카테고리가 없습니다. + 로 추가하세요.
- button "추가" [ref=e68] [cursor=pointer]:
- img [ref=e69]
- text: 추가
- generic [ref=e71]:
- generic [ref=e72] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e73] [cursor=pointer]: Shorts
- generic [ref=e74] [cursor=pointer]: ⭐ 북마크
- generic [ref=e76]:
- text: 상태
- combobox "상태" [ref=e77]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e78]:
- text: 카테고리
- combobox "카테고리" [ref=e79]:
- option "전체" [selected]
- generic [ref=e80]:
- text: 출처
- combobox "출처" [ref=e81]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e82]:
- text: 정렬
- combobox "정렬" [ref=e83]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e85] [cursor=pointer]:
- img [ref=e86]
- table [ref=e92]:
- rowgroup [ref=e93]:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e94]:
- columnheader "영상" [ref=e95]
- columnheader "구독자" [ref=e96]
- columnheader "조회수" [ref=e97]
- columnheader "시간당" [ref=e98]
- columnheader "배율" [ref=e99]
- columnheader "상태" [ref=e100]
- columnheader "카테고리" [ref=e101]
- columnheader "관리" [ref=e102]
- rowgroup [ref=e103]:
- row "로딩 중..." [ref=e104]:
- cell "로딩 중..." [ref=e105]

File diff suppressed because it is too large Load Diff

View File

@ -1,92 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e54]
- generic [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]:
- heading "수집함" [level=1] [ref=e58]
- paragraph [ref=e59]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e61] [cursor=pointer]:
- img [ref=e62]
- text: 사용법
- generic [ref=e66]:
- generic [ref=e67]: 카테고리
- generic [ref=e69]: 로딩...
- button "추가" [ref=e70] [cursor=pointer]:
- img [ref=e71]
- text: 추가
- generic [ref=e73]:
- generic [ref=e74] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e75] [cursor=pointer]: Shorts
- generic [ref=e76] [cursor=pointer]: ⭐ 북마크
- generic [ref=e78]:
- text: 상태
- combobox "상태" [ref=e79]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e80]:
- text: 카테고리
- combobox "카테고리" [ref=e81]:
- option "전체" [selected]
- generic [ref=e82]:
- text: 출처
- combobox "출처" [ref=e83]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e84]:
- text: 정렬
- combobox "정렬" [ref=e85]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e87] [cursor=pointer]:
- img [ref=e88]
- table [ref=e94]:
- rowgroup [ref=e95]:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e96]:
- columnheader "영상" [ref=e97]
- columnheader "구독자" [ref=e98]
- columnheader "조회수" [ref=e99]
- columnheader "시간당" [ref=e100]
- columnheader "배율" [ref=e101]
- columnheader "상태" [ref=e102]
- columnheader "카테고리" [ref=e103]
- columnheader "관리" [ref=e104]
- rowgroup [ref=e105]:
- row "로딩 중..." [ref=e106]:
- cell "로딩 중..." [ref=e107]

File diff suppressed because it is too large Load Diff

View File

@ -1,92 +0,0 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- link [ref=e12] [cursor=pointer]:
- /url: /
- img [ref=e13]
- link [ref=e18] [cursor=pointer]:
- /url: /discover
- img [ref=e19]
- link [ref=e26] [cursor=pointer]:
- /url: /channels
- img [ref=e27]
- link [ref=e32] [cursor=pointer]:
- /url: /collection
- img [ref=e33]
- link [ref=e35] [cursor=pointer]:
- /url: /board
- img [ref=e36]
- link [ref=e38] [cursor=pointer]:
- /url: /publish
- img [ref=e39]
- link [ref=e42] [cursor=pointer]:
- /url: /production
- img [ref=e43]
- button "테마 전환" [ref=e49] [cursor=pointer]
- main [ref=e52]:
- button [ref=e53] [cursor=pointer]:
- img [ref=e54]
- generic [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]:
- heading "수집함" [level=1] [ref=e58]
- paragraph [ref=e59]: 수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.
- button "사용법" [ref=e61] [cursor=pointer]:
- img [ref=e62]
- text: 사용법
- generic [ref=e66]:
- generic [ref=e67]: 카테고리
- generic [ref=e69]: 로딩...
- button "추가" [ref=e70] [cursor=pointer]:
- img [ref=e71]
- text: 추가
- generic [ref=e73]:
- generic [ref=e74] [cursor=pointer]: 🚀 떡상 후보
- generic [ref=e75] [cursor=pointer]: Shorts
- generic [ref=e76] [cursor=pointer]: ⭐ 북마크
- generic [ref=e78]:
- text: 상태
- combobox "상태" [ref=e79]:
- option "전체" [selected]
- option "NEW (수집됨)"
- option "REVIEWING (검토중)"
- option "TARGET (작업대상)"
- option "DONE (완료)"
- option "EXCLUDED (제외)"
- generic [ref=e80]:
- text: 카테고리
- combobox "카테고리" [ref=e81]:
- option "전체" [selected]
- generic [ref=e82]:
- text: 출처
- combobox "출처" [ref=e83]:
- option "전체" [selected]
- option "채널 수집"
- option "검색 수집"
- generic [ref=e84]:
- text: 정렬
- combobox "정렬" [ref=e85]:
- option "배율(조회/구독) ↓" [selected]
- option "시간당 조회수 ↓"
- option "조회수 ↓"
- option "최신순 ↓"
- button "새로고침" [ref=e87] [cursor=pointer]:
- img [ref=e88]
- table [ref=e94]:
- rowgroup [ref=e95]:
- row "영상 구독자 조회수 시간당 배율 상태 카테고리 관리" [ref=e96]:
- columnheader "영상" [ref=e97]
- columnheader "구독자" [ref=e98]
- columnheader "조회수" [ref=e99]
- columnheader "시간당" [ref=e100]
- columnheader "배율" [ref=e101]
- columnheader "상태" [ref=e102]
- columnheader "카테고리" [ref=e103]
- columnheader "관리" [ref=e104]
- rowgroup [ref=e105]:
- row "로딩 중..." [ref=e106]:
- cell "로딩 중..." [ref=e107]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

View File

@ -1,686 +0,0 @@
# 추천 채널 발굴 (떡상 Shorts) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 지역(KR,JP,US) 인기 Shorts 를 주기적으로 검색해 "작은 구독자·고배율" 떡상 채널을 발굴하고, `/recommend` 전용 페이지에서 등록/제외할 수 있게 한다.
**Architecture:** 기존 `YoutubeSearchService`(지역별 Shorts 검색)와 `YoutubeQuotaGuard`(쿼터)를 재사용. 순수 랭킹 로직(`DiscoveryRanker`)으로 채널별 최고 배율 집계·필터 후 `RecommendedChannel` 에 upsert. 스케줄러가 일1회 실행, 전용 페이지 + REST 로 노출.
**Tech Stack:** Spring Boot 3.4 / Java 21, JPA(PostgreSQL, ddl-auto:update), Lombok, Thymeleaf(SSR), 기존 `ApiResponse<T>`/`@RestController` 패턴.
## Global Constraints
- 빌드/실행: `JAVA_HOME=D:/Development/app/JDK/jdk-21.0.5`, `./gradlew.bat`, 포트 8088.
- API 응답은 `global/common/ApiResponse<T>`(`ApiResponse.ok(...)`)로 감싼다.
- 엔티티는 Lombok + `@CreationTimestamp`/`@UpdateTimestamp`. 스키마는 ddl-auto:update(마이그레이션 없음).
- 배율(ratio) 정의 = `viewCount / subscriberCount`(구독자 > 0 일 때만).
- 단위테스트는 `src/test/java/com/hlab/yanalyst/...`, JUnit5 + AssertJ(spring-boot-starter-test).
---
### Task 1: `RecommendedChannel` 엔티티 + Repository
**Files:**
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java`
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java`
**Interfaces:**
- Produces:
- `RecommendedChannel` 엔티티(필드: id, channelId, channelTitle, thumbnailUrl, subscriberCount, status, topVideoId, topVideoTitle, topVideoViewCount, ratio, region, discoveredAt, updatedAt).
- `RecommendedChannelRepository extends JpaRepository<RecommendedChannel, Long>`:
`Optional<RecommendedChannel> findByChannelId(String channelId)`,
`List<RecommendedChannel> findByStatusOrderByRatioDesc(String status)`,
`boolean existsByChannelIdAndStatus(String channelId, String status)`.
- [ ] **Step 1: 엔티티 작성**
```java
package com.hlab.yanalyst.domain.channel;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
/** 자동 발굴된 추천 채널(떡상 Shorts 기반). status: NEW|REGISTERED|EXCLUDED. */
@Entity
@Table(name = "recommended_channels")
@Getter
@Setter
@NoArgsConstructor
public class RecommendedChannel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String channelId;
private String channelTitle;
@Column(length = 2083)
private String thumbnailUrl; // 대표 떡상 영상의 썸네일
private Long subscriberCount;
@Column(nullable = false)
private String status = "NEW";
// 대표(최고 배율) 영상
private String topVideoId;
@Column(length = 500)
private String topVideoTitle;
private Long topVideoViewCount;
private Double ratio; // topVideo viewCount / subscriberCount
private String region; // 발견 지역(KR/JP/US)
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime discoveredAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
```
- [ ] **Step 2: Repository 작성**
```java
package com.hlab.yanalyst.domain.channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface RecommendedChannelRepository extends JpaRepository<RecommendedChannel, Long> {
Optional<RecommendedChannel> findByChannelId(String channelId);
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
boolean existsByChannelIdAndStatus(String channelId, String status);
}
```
- [ ] **Step 3: 컴파일 확인**
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 4: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java \
src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java
git commit -m "feat(discover): RecommendedChannel 엔티티+Repository 추가"
```
---
### Task 2: `DiscoveryRanker` 순수 랭킹 로직 (TDD)
**Files:**
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java`
- Test: `src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java`
**Interfaces:**
- Consumes: `com.hlab.yanalyst.web.dto.YoutubeSearchResultDto`
(getter: `getChannelId()`, `getChannelTitle()`, `getThumbnailUrl()`, `getSubscriberCount():Long`,
`getViewCount():Long`, `getVideoId()`, `getTitle()`, `getChannelCountry()`).
- Produces:
- `DiscoveryRanker.Candidate` record(channelId, channelTitle, thumbnailUrl, subscriberCount, topVideoId, topVideoTitle, topVideoViewCount, ratio(double), region).
- `static List<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers, double minRatio, java.util.Set<String> excludeChannelIds)`.
- [ ] **Step 1: 실패 테스트 작성**
```java
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class DiscoveryRankerTest {
private YoutubeSearchResultDto item(String ch, long subs, String vid, long views) {
YoutubeSearchResultDto d = new YoutubeSearchResultDto();
d.setChannelId(ch); d.setChannelTitle(ch + "_title"); d.setThumbnailUrl("thumb_" + vid);
d.setSubscriberCount(subs); d.setVideoId(vid); d.setTitle("v_" + vid); d.setViewCount(views);
d.setChannelCountry("KR");
return d;
}
@Test
void rank_채널별_최고배율_선정_필터_정렬() {
List<YoutubeSearchResultDto> items = List.of(
item("A", 1000, "a1", 50000), // ratio 50 (작은채널·고배율)
item("A", 1000, "a2", 10000), // ratio 10 (A의 비대표)
item("B", 5_000_000, "b1", 60_000_000), // subs 과다 → 제외(maxSubs)
item("C", 2000, "c1", 4000), // ratio 2 → minRatio 미달 제외
item("D", 1000, "d1", 30000) // ratio 30
);
List<DiscoveryRanker.Candidate> out =
DiscoveryRanker.rank(items, 100_000, 5.0, Set.of());
// A(50), D(30) 만 남고 배율 내림차순. A는 최고배율 영상 a1 선택.
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("A", "D");
assertThat(out.get(0).topVideoId()).isEqualTo("a1");
assertThat(out.get(0).ratio()).isEqualTo(50.0);
}
@Test
void rank_제외채널_및_구독자0_제거() {
List<YoutubeSearchResultDto> items = List.of(
item("A", 1000, "a1", 50000), // ratio 50 이지만 제외목록
item("E", 0, "e1", 9999), // subs 0 → 배율 계산 불가 제외
item("F", 1000, "f1", 20000) // ratio 20
);
List<DiscoveryRanker.Candidate> out =
DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A"));
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F");
}
}
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain`
Expected: FAIL — `DiscoveryRanker` 클래스 없음(cannot find symbol).
- [ ] **Step 3: `DiscoveryRanker` 구현**
```java
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 검색된 Shorts 목록 → 채널별 최고 배율 후보 집계·필터·정렬(순수 로직). */
public final class DiscoveryRanker {
private DiscoveryRanker() {}
public record Candidate(String channelId, String channelTitle, String thumbnailUrl, Long subscriberCount,
String topVideoId, String topVideoTitle, Long topVideoViewCount,
double ratio, String region) {}
public static List<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers,
double minRatio, Set<String> excludeChannelIds) {
Map<String, Candidate> best = new LinkedHashMap<>();
for (YoutubeSearchResultDto it : items) {
String ch = it.getChannelId();
Long subs = it.getSubscriberCount();
Long views = it.getViewCount();
if (ch == null || subs == null || subs <= 0 || views == null) continue;
if (excludeChannelIds.contains(ch)) continue;
if (subs > maxSubscribers) continue;
double ratio = (double) views / subs;
if (ratio < minRatio) continue;
Candidate prev = best.get(ch);
if (prev == null || ratio > prev.ratio()) {
best.put(ch, new Candidate(ch, it.getChannelTitle(), it.getThumbnailUrl(), subs,
it.getVideoId(), it.getTitle(), views, ratio, it.getChannelCountry()));
}
}
List<Candidate> out = new ArrayList<>(best.values());
out.sort(Comparator.comparingDouble(Candidate::ratio).reversed());
return out;
}
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain`
Expected: PASS
- [ ] **Step 5: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java \
src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java
git commit -m "feat(discover): 채널 발굴 랭킹 로직 DiscoveryRanker + 단위테스트"
```
---
### Task 3: `ChannelDiscoveryService` (검색 재사용 + upsert) + 설정
**Files:**
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java`
- Modify: `src/main/resources/application.yml` (discovery 설정 추가)
**Interfaces:**
- Consumes:
- `YoutubeSearchService.searchYoutubeVideos(YoutubeSearchCondition)``YoutubeSearchPageDto.getItems():List<YoutubeSearchResultDto>`.
`YoutubeSearchCondition` 필드: `setRegions(List<String>)`, `setFormat("SHORTS")`, `setKeyword(String)`, `setPeriodDays(Integer)`.
- `YoutubeQuotaGuard.remaining():long`, `tryConsume(long):boolean`.
- `ChannelRepository`(기존) — 등록된 channelId 제외용. 메서드 `existsByChannelId(String)` 가 없으면 이 태스크에서 추가.
- `RecommendedChannelRepository`(Task 1), `DiscoveryRanker.rank(...)`(Task 2).
- Produces:
- `Map<String,Object> runDiscovery()` — 검색→랭킹→upsert 후 요약 반환.
- [ ] **Step 1: `ChannelRepository` 에 존재여부 메서드 보강(없으면)**
`src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java` 에 다음이 없으면 추가:
```java
boolean existsByChannelId(String channelId);
```
- [ ] **Step 2: 설정 추가**`application.yml``hlab:` 블록 아래에 병합
```yaml
hlab:
scheduler:
channel-discovery:
enabled: ${CHANNEL_DISCOVERY_ENABLED:true}
cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30
discovery:
regions: ${DISCOVERY_REGIONS:KR,JP,US}
max-subscribers: ${DISCOVERY_MAX_SUBS:100000}
min-ratio: ${DISCOVERY_MIN_RATIO:5.0}
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
top-n: ${DISCOVERY_TOP_N:30}
```
> 주의: 기존 `hlab.scheduler.*`, `hlab.youtube.*` 키와 같은 `hlab:` 트리에 병합할 것(중복 `hlab:` 매핑 금지).
- [ ] **Step 3: 서비스 구현**
```java
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
import com.hlab.yanalyst.service.YoutubeSearchService;
import com.hlab.yanalyst.web.dto.YoutubeSearchCondition;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelDiscoveryService {
/** search.list 1회 추정 쿼터(units). */
private static final long SEARCH_QUOTA = 100;
private final YoutubeSearchService youtubeSearchService;
private final YoutubeQuotaGuard quotaGuard;
private final ChannelRepository channelRepository;
private final RecommendedChannelRepository recommendedChannelRepository;
@Value("${hlab.discovery.regions:KR,JP,US}")
private String regionsCsv;
@Value("${hlab.discovery.max-subscribers:100000}")
private long maxSubscribers;
@Value("${hlab.discovery.min-ratio:5.0}")
private double minRatio;
@Value("${hlab.discovery.period-days:14}")
private int periodDays;
@Transactional
public Map<String, Object> runDiscovery() {
List<String> regions = Arrays.stream(regionsCsv.split(","))
.map(String::trim).filter(s -> !s.isBlank()).toList();
List<YoutubeSearchResultDto> all = new ArrayList<>();
List<String> searchedRegions = new ArrayList<>();
for (String region : regions) {
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
break;
}
try {
YoutubeSearchCondition cond = new YoutubeSearchCondition();
cond.setRegions(List.of(region));
cond.setFormat("SHORTS");
cond.setPeriodDays(periodDays);
// 광범위 검색: 키워드 없이 인기 Shorts. (구현 시 q 없이도 동작하는지 확인,
// 필요하면 region 인기영상으로 보강 — 스펙 §4.2)
var page = youtubeSearchService.searchYoutubeVideos(cond);
if (page != null && page.getItems() != null) all.addAll(page.getItems());
searchedRegions.add(region);
} catch (Exception e) {
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
}
}
// 이미 등록/제외한 채널 제외
Set<String> exclude = new HashSet<>();
for (var rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) exclude.add(rc.getChannelId());
List<DiscoveryRanker.Candidate> ranked = DiscoveryRanker.rank(all, maxSubscribers, minRatio, exclude);
int saved = 0;
for (DiscoveryRanker.Candidate c : ranked) {
if (channelRepository.existsByChannelId(c.channelId())) continue; // 이미 내 채널
RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId())
.orElseGet(RecommendedChannel::new);
if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue;
// 더 좋은(높은 배율) 메트릭일 때만 갱신
if (rc.getId() != null && rc.getRatio() != null && c.ratio() <= rc.getRatio()) continue;
rc.setChannelId(c.channelId());
rc.setChannelTitle(c.channelTitle());
rc.setThumbnailUrl(c.thumbnailUrl());
rc.setSubscriberCount(c.subscriberCount());
rc.setStatus("NEW");
rc.setTopVideoId(c.topVideoId());
rc.setTopVideoTitle(c.topVideoTitle());
rc.setTopVideoViewCount(c.topVideoViewCount());
rc.setRatio(c.ratio());
rc.setRegion(c.region());
recommendedChannelRepository.save(rc);
saved++;
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("regions", searchedRegions);
summary.put("candidates", ranked.size());
summary.put("saved", saved);
summary.put("quotaRemaining", quotaGuard.remaining());
log.info("[Discovery] 완료: {}", summary);
return summary;
}
}
```
- [ ] **Step 4: 컴파일 확인 + 기존 단위테스트 통과**
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain`
Expected: `BUILD SUCCESSFUL`(컴파일 + 테스트). 컴파일 에러 시 `YoutubeSearchCondition` setter/`YoutubeSearchPageDto.getItems()` 시그니처 재확인.
- [ ] **Step 5: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java \
src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java \
src/main/resources/application.yml
git commit -m "feat(discover): ChannelDiscoveryService(검색→떡상 채널 upsert) + 설정"
```
---
### Task 4: 스케줄러 연결
**Files:**
- Modify: `src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java`
**Interfaces:**
- Consumes: `ChannelDiscoveryService.runDiscovery()`(Task 3).
- [ ] **Step 1: 의존성 주입 + 스케줄 메서드 추가**
`ScheduledCollectionService` 의 final 필드에 추가(생성자는 @RequiredArgsConstructor):
```java
private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService;
@org.springframework.beans.factory.annotation.Value("${hlab.scheduler.channel-discovery.enabled:true}")
private boolean discoveryEnabled;
@org.springframework.scheduling.annotation.Scheduled(cron = "${hlab.scheduler.channel-discovery.cron:0 30 4 * * *}")
public void scheduledDiscovery() {
if (!discoveryEnabled) {
log.info("[Scheduler] 추천 채널 발굴 비활성화됨");
return;
}
var result = channelDiscoveryService.runDiscovery();
log.info("[Scheduler] 추천 채널 발굴 완료: {}", result);
}
```
- [ ] **Step 2: 컴파일 확인**
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 3: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java
git commit -m "feat(discover): 추천 채널 발굴 일별 스케줄 연결"
```
---
### Task 5: `RecommendedChannelController` (목록/등록/제외/수동실행)
**Files:**
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java`
**Interfaces:**
- Consumes: `RecommendedChannelRepository`(Task1), `ChannelDiscoveryService.runDiscovery()`(Task3),
`ChannelService.saveChannelFromUrl(String)`(기존) — URL `https://www.youtube.com/channel/{channelId}`.
- Produces(REST):
- `GET /api/recommended-channels``ApiResponse<List<RecommendedChannel>>`(status=NEW, 배율 desc).
- `POST /api/recommended-channels/{id}/register``ApiResponse<Void>`(Channel 생성 + status=REGISTERED).
- `POST /api/recommended-channels/{id}/exclude``ApiResponse<Void>`(status=EXCLUDED).
- `POST /api/recommended-channels/run``ApiResponse<Map<String,Object>>`(수동 발굴).
- [ ] **Step 1: 컨트롤러 작성**
```java
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/recommended-channels")
@RequiredArgsConstructor
@Tag(name = "Recommended Channel API", description = "자동 발굴된 추천 채널(떡상 Shorts)")
public class RecommendedChannelController {
private final RecommendedChannelRepository repository;
private final ChannelDiscoveryService discoveryService;
private final ChannelService channelService;
@GetMapping
@Operation(summary = "추천 채널 목록", description = "status=NEW 를 배율 내림차순으로 반환")
public ApiResponse<List<RecommendedChannel>> list() {
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW"));
}
@PostMapping("/{id}/register")
@Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리")
@Transactional
public ApiResponse<Void> register(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
rc.setStatus("REGISTERED");
repository.save(rc);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/exclude")
@Operation(summary = "추천 제외", description = "다음 발굴에서 다시 뜨지 않게 EXCLUDED 처리")
@Transactional
public ApiResponse<Void> exclude(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
rc.setStatus("EXCLUDED");
repository.save(rc);
return ApiResponse.ok(null);
}
@PostMapping("/run")
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
public ApiResponse<Map<String, Object>> run() {
return ApiResponse.ok(discoveryService.runDiscovery());
}
}
```
- [ ] **Step 2: 컴파일 확인**
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 3: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java
git commit -m "feat(discover): 추천 채널 REST(목록/등록/제외/수동실행)"
```
---
### Task 6: `/recommend` 전용 페이지 + 라우트 + 사이드바 메뉴
**Files:**
- Create: `src/main/resources/templates/recommend.html`
- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (라우트 추가)
- Modify: `src/main/resources/templates/layout/sidebar.html` (메뉴 추가)
**Interfaces:**
- Consumes(JS fetch): `GET /api/recommended-channels`, `POST /{id}/register`, `POST /{id}/exclude`, `POST /run`.
- [ ] **Step 1: WebController 라우트 추가** (`discover()` 메서드 옆에 동일 패턴)
```java
@GetMapping("/recommend")
public String recommend(org.springframework.ui.Model model) {
model.addAttribute("currentPage", "recommend");
return "recommend";
}
```
- [ ] **Step 2: 사이드바 메뉴 추가**`sidebar.html` 의 발굴(`/discover`) 항목 바로 아래에 삽입
```html
<a th:href="@{/recommend}" class="nav-item" th:classappend="${currentPage == 'recommend'} ? 'active'">
<i data-lucide="sparkles" class="nav-icon"></i><span class="nav-text">추천 채널</span>
</a>
```
- [ ] **Step 3: 페이지 작성** (기존 `discover.html` 의 layout/base 패턴 따름)
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head><title>h-lab - 추천 채널</title></head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div><h1>추천 채널</h1><p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널</p></div>
<div class="actions">
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
</div>
</div>
<div id="status" class="text-sm mb-3" style="display:none;"></div>
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div>
<style>
.rc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; }
.rc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; }
.rc-body { padding:12px; }
.rc-ratio { display:inline-block; background:#ef4444; color:#fff; font-weight:800; font-size:0.8rem; padding:2px 8px; border-radius:999px; }
.rc-actions { display:flex; gap:8px; margin-top:10px; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/recommended-channels';
async function api(url, opts){ const r=await fetch(url,opts); const j=await r.json().catch(()=>({})); if(!r.ok||(j&&j.success===false)) throw new Error((j&&j.message)||('HTTP '+r.status)); return j.data; }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
async function load(){
const grid=document.getElementById('grid');
try {
const list=await api(API);
if(!list.length){ grid.innerHTML='<div class="text-muted">아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.</div>'; return; }
grid.innerHTML=list.map(c=>
'<div class="rc-card" id="rc-'+c.id+'">'
+ '<a href="https://www.youtube.com/watch?v='+esc(c.topVideoId)+'" target="_blank"><img class="rc-thumb" src="'+esc(c.thumbnailUrl)+'" loading="lazy"></a>'
+ '<div class="rc-body">'
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
+ '<div class="rc-actions">'
+ '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>'
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'
+ '</div>'
+ '</div></div>').join('');
if(window.lucide) lucide.createIcons();
} catch(e){ grid.innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
}
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('등록 실패: '+e.message); } }
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('제외 실패: '+e.message); } }
async function runDiscovery(){
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)';
try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(','))+''; await load(); }
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
}
load();
/*]]>*/
</script>
</div>
</body>
</html>
```
- [ ] **Step 4: 실행 검증**
Run(앱 기동):
```bash
JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat bootRun --console=plain # 백그라운드
```
- 브라우저/`curl`로 `http://localhost:8088/recommend` 200, 사이드바에 "추천 채널" 노출 확인.
- `curl -X POST http://localhost:8088/api/recommended-channels/run``{success:true, data:{regions,candidates,saved,...}}` 확인(쿼터/검색 동작). 결과가 빈약하면 스펙 §4.2(검색 API 보강) 적용.
- [ ] **Step 5: 커밋**
```bash
git add src/main/resources/templates/recommend.html \
src/main/java/com/hlab/yanalyst/web/WebController.java \
src/main/resources/templates/layout/sidebar.html
git commit -m "feat(discover): 추천 채널 전용 페이지 /recommend + 사이드바 메뉴"
```
---
## Self-Review
- **Spec coverage:** §3 흐름→Task3/4, §4.1 엔티티→Task1, 랭킹/필터/dedup→Task2/3, §4.4 REST→Task5, §4.5 페이지/메뉴→Task6, §5 설정→Task3, §6 쿼터→Task3(quotaGuard), §8 테스트→Task2. 누락 없음.
- **검색 API 리스크(§4.2):** Task3/Task6 Step4 에 "결과 빈약 시 보강" 명시. 키워드 없는 search.list 가 빈 결과면 `YoutubeSearchService` 에 region 인기영상 경로 추가가 후속 필요(플랜 외 보강 포인트로 표시).
- **Type 일관성:** `DiscoveryRanker.Candidate` accessor(record) 명칭이 Task2 정의와 Task3 사용처 일치(channelId(), ratio() 등). `RecommendedChannel` setter 명칭이 Task1 필드와 일치.
- **Placeholder:** 모든 코드 step 실제 코드 포함. TBD 없음.

View File

@ -1,133 +0,0 @@
# yt-dlp 다운로드 → 재가공 파이프라인 자동연결 설계
- 날짜: 2026-06-24
- 상태: 설계 확정(구현 대기)
- 관련: `2026-06-12-subtitle-timeline-studio-design.md`(자막 타임라인 스튜디오)
## 1. 목적
재가공(`/rework/{id}`) 화면에서 **"원본 다운로드" 버튼 한 번**으로
`yt-dlp`가 저장된 `videoId`로 영상을 받아 서버에 캐시하고, 그 파일을 그대로
기존 Python 파이프라인(`/transcribe`, `/render`)에 흘려보낸다. 결과적으로
지금까지 필요했던 **수동 다운로드 + 수동 업로드** 단계를 전사·렌더 양쪽에서 모두 제거한다.
## 2. 배경 / 현재 구조
- 백엔드: Spring Boot 3.4 / Java 21, Thymeleaf SSR. 로컬 실행(`bootRun`, 포트 8088, Dockerfile 없음).
- 영상 처리는 원격 Python 마이크로서비스(`PYTHON_BASE_URL`, 기본 `http://h-python.tolag.shop`)의
`/transcribe`(faster-whisper), `/render`(ffmpeg)를 `pythonRestTemplate`로 호출.
- 현재 흐름: 사용자가 **유튜브에서 수동 다운로드 → 브라우저에서 파일 업로드**.
업로드한 File을 JS변수 `UPLOADED_FILE`에 담아 전사·렌더가 클라이언트에서 재사용.
렌더 버튼은 `UPLOADED_FILE`이 없으면 "먼저 업로드하세요"로 막힘.
- 로컬 설치 확인: `yt-dlp 2026.03.17`(`C:\...\Python313\Scripts\yt-dlp`), `ffmpeg 8.0.1`(`D:\utils\...`).
- `ChannelVideo`: 전체 URL 없이 `videoId`만 저장(+`is_shorts` 플래그). URL은 `https://www.youtube.com/watch?v={videoId}`로 구성.
### 실행 위치 결정
yt-dlp는 **Spring이 로컬에서 직접 실행(ProcessBuilder)**, 받은 mp4를 기존처럼 원격 Python으로 전송한다.
- 근거: yt-dlp가 로컬 설치됨, h-lab도 로컬 실행, 유튜브는 데이터센터 IP를 자주 차단하므로 가정용 IP에서 받는 게 안정적.
## 3. 사용자 흐름
```
[재가공 /rework/{id}] "원본 다운로드" 클릭
│ POST /api/curation/videos/{id}/download
[Spring] videoId 검증 → yt-dlp(ProcessBuilder) → {download.dir}/{id}.mp4 캐시 저장
[Spring] 캐시 파일 → 원격 Python /transcribe → 세그먼트 저장 + hasScript=true
[화면] 세그먼트 표시 + 서버파일 보유 플래그 set
"말없는구간 제거·렌더" → 같은 캐시 파일 재사용(업로드 불필요)
```
## 4. 컴포넌트
### 4.1 `VideoDownloadService` (신규, `domain/channel`)
서버사이드 yt-dlp 실행과 다운로드 캐시를 담당.
- `File download(Long channelVideoId)`
1. `ChannelVideo` 조회, `videoId` 형식 검증: 정규식 `^[A-Za-z0-9_-]{11}$`. 불일치 시 `IllegalArgumentException`.
2. 출력 경로 `{download.dir}/{channelVideoId}.mp4` 계산(폴더 없으면 생성).
3. `buildCommand(videoId, outPath)`로 만든 인자 리스트를 ProcessBuilder에 전달(**shell 미사용**).
4. 프로세스 실행, `timeout-seconds` 초과 시 강제종료 + 예외. 비정상 종료 시 stderr 일부를 메시지에 담아 예외.
5. 결과 File 반환.
- `Optional<File> cachedFile(Long id)` / `boolean isCached(Long id)`: 캐시 존재·반환(렌더 재사용용).
- `List<String> buildCommand(String videoId, Path outPath)`**순수 메서드(프로세스 실행 없음, 단위테스트 대상)**:
```
{ytdlp.bin}
--no-playlist
--force-overwrites
-f bv*[height<={max-height}]+ba/b[height<={max-height}]/b
--merge-output-format mp4
[--ffmpeg-location {ffmpeg-location}] # 값이 있을 때만
-o {outPath}
https://www.youtube.com/watch?v={videoId}
```
### 4.2 `ChannelService` 리팩터 — Resource 기반 공통화
전사·렌더 핵심 로직을 `Resource` 입력으로 추출해 MultipartFile(기존)과 File(캐시)이 공유.
- 내부 공통: `doTranscribe(ChannelVideo, Resource, language)`, `doRender(ChannelVideo, Resource, pad, minGap, speed)`.
- 기존 `transcribeFromFile(id, MultipartFile, lang)` / `renderTrimmed(id, MultipartFile, ...)`
Resource 어댑터를 거쳐 공통 메서드 호출하도록 유지(외부 동작 불변).
- 신규: `transcribeFromCached(id, lang)`, `renderTrimmedFromCached(id, pad, minGap, speed)`
`VideoDownloadService.cachedFile(id)``FileSystemResource` 생성 후 공통 메서드 호출.
캐시 없으면 `IllegalArgumentException("원본 영상이 없습니다. 먼저 '원본 다운로드'를 실행하세요.")`.
- `toFileResource(File)` 헬퍼 추가(파일명 보존). 기존 `toFileResource(MultipartFile)` 유지.
### 4.3 `ChannelVideoCurationController` 엔드포인트
- `POST /{id}/download` — yt-dlp 다운로드 + 캐시 전사를 한 호출로 수행.
응답: `{ downloaded:true, sizeBytes, ...전사결과(hasScript, language, duration, transcript, segments) }`.
(서비스 위임: `VideoDownloadService.download``ChannelService.transcribeFromCached`.)
- 기존 `POST /{id}/render``file`을 **선택값**으로 변경:
- `file` 있으면 기존대로(업로드 렌더).
- `file` 없으면 `renderTrimmedFromCached` 사용(캐시 없으면 400, 메시지 노출).
- `CurationService`는 컨트롤러↔ChannelService 사이 기존 위임 패턴을 따라 대응 메서드 추가.
### 4.4 `rework.html` UI
- "영상 업로드·전사" 버튼 옆에 **"원본 다운로드"** 버튼 추가(아이콘 `download`).
- 클릭 → 스피너/상태표시 → `POST /{id}/download`.
성공 시 기존 전사 성공 핸들러 재사용(세그먼트 렌더), 전역 플래그 `HAS_SERVER_FILE=true` set.
- `renderVideo()` 수정: `UPLOADED_FILE` 없어도 `HAS_SERVER_FILE`이면 진행.
업로드 파일 있으면 multipart로 전송, 없으면 `file` 없이 `/render` 호출(서버 캐시 사용).
## 5. 설정 (`application.yml`, 모두 기본값 有)
```yaml
ytdlp:
bin: ${YTDLP_BIN:yt-dlp}
ffmpeg-location: ${FFMPEG_LOCATION:} # 비우면 PATH 사용
max-height: ${YTDLP_MAX_HEIGHT:1080}
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
download:
dir: ${DOWNLOAD_DIR:downloads} # 캐시 폴더(상대경로 = 작업디렉토리 기준)
```
`downloads/``.gitignore`에 추가(영상 캐시 커밋 방지).
## 6. 에러 처리
- videoId 형식 오류 → 400(`IllegalArgumentException` → `GlobalExceptionHandler`).
- yt-dlp 실행 실패/타임아웃 → 500, 로그에 stderr 요약. 화면에 "다운로드 실패" 상태 표시.
- 렌더 시 캐시·세그먼트 없음 → 400, 사용자에게 다음 행동 안내 메시지.
- 외부로 시크릿 노출 없음(여기선 API 키 불필요). 명령은 인자 리스트 전달로 셸 인젝션 차단.
## 7. 동기/비동기
MVP는 기존 전사·렌더와 동일하게 **동기 호출 + 프론트 스피너**. `timeout-seconds` 가드만 둔다.
비동기 작업큐/진행률 %는 범위 외(추후).
## 8. 테스트 (`src/test` 신규 생성)
프로세스 실행 없이 검증 가능한 단위 위주:
- `buildCommand`: 인자 순서/포맷 문자열/ffmpeg-location 유무 분기/URL 구성.
- videoId 검증: 정상 11자 통과, 비정상(길이/문자) 거부.
- 렌더 폴백 분기: `file` 없음 + 캐시 없음 → 예외, 캐시 있음 → 캐시 경로 사용(서비스 레벨, Python 호출은 모킹).
## 9. 범위에서 제외 (YAGNI)
- 비동기 작업큐 / 진행률 표시
- 임의 URL 붙여넣기(이번엔 저장된 videoId 버튼만)
- 캐시 자동정리·만료 정책
- 다운로드 화질/포맷 사용자 선택 UI(설정값 고정)

View File

@ -1,118 +0,0 @@
# AI 자막 2종(음성/화면) → 한국어 SRT 설계
- 날짜: 2026-06-25
- 상태: 설계 확정(구현 대기)
- 관련: 제거된 화면 자막 OCR의 대체
## 1. 목적
재가공(`/rework/{id}`) 화면에서 한 영상의 자막을 **두 갈래로** 한국어 SRT까지 만든다:
- **왼쪽 = 음성 자막**: Whisper 전사(밀리초 정밀 타임스탬프) → 한국어 번역
- **오른쪽 = 화면 자막**: Gemini가 유튜브 URL을 보고 화면에 박힌 자막 추출 → 한국어 번역
둘을 **좌우 분할 화면으로 동시에 표시**하고, 각각 **SRT로 다운로드**한다.
배경: Tesseract OCR은 스타일 자막을 못 읽어 제거됨. 화면 자막은 Gemini가 잘 읽고, 음성은
이미 있는 Whisper가 정밀한 타임스탬프를 준다 → 각 강점을 살려 2종을 만든다.
### 타임스탬프 정밀도(중요)
- **음성(Whisper)**: 밀리초 정밀 → 거의 완벽.
- **화면(Gemini)**: 초당 1프레임 샘플링, MM:SS → **~1초 근사**(완벽 불가, 불가피).
## 2. 배경 / 현재 구조 (재사용 대상)
- `ChannelVideoScript`(videoId당 1건): Whisper 전사 결과(segments_json) 저장. 스크립트 리스트·SRT·번역이 공유.
- **음성 한국어 = 기존 자산 재사용**:
- 전사: `ChannelService.transcribeFromCached`(받은 원본 mp4 → Whisper).
- 번역(세그먼트별): `CurationService.translateScript(id, "ko", "segments")` → 세그먼트와 1:1 정렬된 한국어 텍스트 배열(`texts`). LibreTranslate `translateBatch` 사용.
- 즉 **왼쪽(음성)** = Whisper 세그먼트(타임코드) + 그 한국어 번역 배열.
- **화면 한국어 = 신규(Gemini)**: 유튜브 URL → Gemini → 한국어 세그먼트.
- SRT 생성기: 프론트 `buildSrt(segs, speed)` 재사용(세그먼트 → SRT 문자열).
- `ChannelVideo``videoId`만 저장 → URL은 `watch?v={videoId}`.
## 3. 사용자 흐름
```
[재가공 /rework/{id}] "AI 자막(음성/화면)" 생성 클릭
├─(왼쪽: 음성) 저장된 Whisper 세그먼트 필요(없으면 '전사' 안내)
│ → translate(format=segments) 로 한국어 배열 → (세그먼트 타임코드 + 한국어) = 음성 한국어 세그먼트
└─(오른쪽: 화면) POST /{id}/gemini-subtitles
→ videoId→URL → Gemini REST(file_data + 프롬프트, responseMimeType=json)
→ 한국어 화면 세그먼트
[화면] 좌우 분할: 왼쪽 음성 / 오른쪽 화면, 각각 [00:00] 한국어 리스트
각 패널의 "SRT 다운로드" → 음성 SRT / 화면 SRT (둘 다 한국어)
```
## 4. 컴포넌트
### 4.1 `GeminiSubtitleService` (신규, `com.hlab.yanalyst.service`)
유튜브 URL을 Gemini REST로 보내 **화면 자막 → 한국어 세그먼트**를 받는다.
- `List<ScriptResponseDto.Segment> fetchKoreanScreenSubtitles(String videoId)`
1. URL: `https://www.youtube.com/watch?v={videoId}`.
2. `buildRequest(videoId, prompt)`: contents.parts=[{text:프롬프트}, {file_data:{file_uri:URL}}],
generationConfig.responseMimeType=`application/json`,
responseSchema=array(object{start:number(초), end:number(초), text:string(한국어)}).
프롬프트: "영상에 **화면으로 표시되는(박힌) 자막만** 추출해 한국어로 번역. 음성은 무시.
JSON 배열 [{start,end,text}] 로만 출력. start/end는 초(소수 허용), text는 한국어."
3. `POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}`
(`geminiRestTemplate`, read timeout 5분).
4. `candidates[0].content.parts[0].text` → JSON 파싱 → 세그먼트 리스트.
- 순수 메서드(HTTP 없음, **단위테스트**):
- `buildRequest(videoId, prompt)` → 요청 Map(parts/URL/responseMimeType 검증)
- `extractSegments(ObjectMapper, responseJson)``List<Segment>`
(candidates→parts→text의 JSON 배열 파싱, 코드펜스 ```json 방어 제거 후 파싱, 깨지면 빈 리스트)
- 설정: `@Value gemini.api-key`, `gemini.model`.
### 4.2 컨트롤러
- `POST /{id}/gemini-subtitles``curationService.geminiScreenSubtitles(id)`
`{ segments: [한국어 화면 세그먼트] }`. 빈 결과면 안내(영상에 화면 자막 없음/비공개).
- (음성 한국어는 **기존 `POST /{id}/translate?target=ko&format=segments`** 그대로 사용 — 신규 없음.)
### 4.3 `CurationService.geminiScreenSubtitles(Long id)`
- `find(id)``geminiSubtitleService.fetchKoreanScreenSubtitles(videoId)``{segments}`.
- **저장하지 않음(transient)** — 화면 자막은 표시·다운로드용. (음성은 기존 스크립트를 그대로 둠.)
### 4.4 `rework.html` — 좌우 분할 패널
- 새 섹션 "AI 자막 (음성 / 화면)" + 생성 버튼.
- 생성 시:
- 왼쪽(음성): 저장된 Whisper 세그먼트 있으면 `translate(format=segments)`로 한국어 배열 받아
(세그먼트 타임코드 + 한국어) 리스트 렌더. 없으면 "먼저 '전사' 필요" 안내.
- 오른쪽(화면): `gemini-subtitles` 호출 → 한국어 세그먼트 리스트 렌더.
- 각 패널 하단 **"SRT 다운로드"**(`buildSrt` 재사용; 파일명 `audio_ko_{id}.srt` / `screen_ko_{id}.srt`).
- 세그먼트 클릭 시 기존 `seekTo`로 왼쪽 플레이어 이동(선택, 음성 패널에 적용).
## 5. 설정 (`application.yml`)
```yaml
gemini:
api-key: ${GEMINI_API_KEY:} # AI Studio 키(무료 티어)
model: ${GEMINI_MODEL:gemini-2.5-flash} # 유튜브 URL 지원, 무료 티어
```
`RestTemplateConfig``geminiRestTemplate` 빈 추가(read timeout 5분).
## 6. 에러 처리
- 키 없음/401 → "GEMINI_API_KEY 미설정/무효" 안내.
- 비공개·화면자막 없음·빈 결과 → 안내 메시지(오른쪽 패널만 영향).
- 429(무료 한도 초과) → 한도 안내.
- JSON 파싱 실패 → 코드펜스 제거 후 재시도, 실패 시 원문 일부 담은 예외.
- 음성 패널: 전사 세그먼트 없으면 "전사 먼저" 안내(번역 호출 안 함).
- 시크릿(키)은 로그/응답 비노출.
## 7. 범위 제외 (YAGNI)
- 화면 자막 영구 저장(이번엔 transient: 표시·다운로드만)
- 원본 언어 SRT(이번엔 둘 다 한국어)
- 음성 자동 전사 트리거(전사는 기존 버튼으로 선행; 이 기능은 있으면 번역만)
- 비동기 작업큐, 무료 티어 사용량 모니터링, 타임스탬프 정밀 보정
## 8. 테스트 (`src/test`)
- `GeminiSubtitleService.buildRequest`: parts에 프롬프트+file_data(youtube URL), responseMimeType=application/json.
- `extractSegments`: 정상 JSON 배열 파싱 / 코드펜스 감싼 응답 제거 후 파싱 / 깨진·빈 응답 → 빈 리스트.
- 실제 Gemini·Whisper 호출은 라이브 검증(짧은 공개 쇼츠: 음성/화면 각각 한국어·타임코드 확인).

View File

@ -1,125 +0,0 @@
# 추천 채널 발굴 (떡상 Shorts 기반) 설계
- 날짜: 2026-06-25
- 상태: 설계 확정(구현 대기)
## 1. 목적
사이트가 **주기적으로(일 1회) 지역별 인기 Shorts 를 광범위 검색**해, "구독자는 적은데 조회수가
폭발한"(떡상) 채널을 자동 발굴하고, **상위 N개를 추천 채널로 모아** 사용자가 `/recommend` 페이지에서
접속해 보고, 마음에 들면 내 채널로 등록하거나 제외할 수 있게 한다.
기존 `/discover` 발굴은 이미 수집된 **영상** 중 떡상 후보를 찾는 것이고, 이 기능은 새로운 **채널**을
능동적으로 발굴해 추천하는 별도 기능이다.
## 2. 배경 / 재사용 대상
- `service/YoutubeSearchService.searchYoutubeVideos(YoutubeSearchCondition)`: 지역(regionCode) 단위로
YouTube search.list 검색 + 각 영상의 채널 구독자/메타 조회. 결과에 channelId·channelTitle·구독자·조회수 포함.
**광범위 Shorts 검색에 재사용**(type=video, videoDuration=short, order=viewCount, regionCode 별).
- `global/schedule/YoutubeQuotaGuard`: 일일 쿼터 예산 가드. → 발굴도 이 가드 안에서 동작.
- `global/schedule/ScheduledCollectionService`: 기존 일별 스케줄. → 발굴 작업을 여기에 추가(또는 병행).
- `domain/channel/Channel` + `ChannelService.saveChannelFromUrl`/채널 저장: "내 채널 등록" 시 재사용.
- 배율(조회수÷구독자) 개념: 기존 `viewsPerSubRatio` 와 동일 기준.
## 3. 데이터 흐름
```
[스케줄러 일1회 (cron 설정)] enabled & 쿼터 여유?
└─ regions(KR,JP,US) 각각:
YoutubeSearchService 로 인기 Shorts 검색(order=viewCount, short, pages 설정)
→ 영상별 채널 구독자 → 배율 계산
채널 단위 집계: 채널별 '최고 배율 영상' 1건 선정
필터: 구독자 ≤ maxSubscribers AND 배율 ≥ minRatio
AND 이미 등록된 채널(Channel) 아님
AND RecommendedChannel.status = EXCLUDED 아님
RecommendedChannel upsert(channelId 기준): 메타(대표영상·배율·구독자·발견일) 저장/갱신
[/recommend 페이지] status=NEW 를 배율 desc 정렬, 상위 N 표시
카드: 썸네일·채널명·구독자·대표 떡상영상(제목·조회수·배율)·샘플 링크
액션: [내 채널 등록] → Channel 생성 + status=REGISTERED
[제외] → status=EXCLUDED (다음 발굴에서 제외)
```
## 4. 컴포넌트
### 4.1 `RecommendedChannel` 엔티티 + `RecommendedChannelRepository` (신규, `domain/channel`)
- 필드: `id`, `channelId`(unique), `channelTitle`, `thumbnailUrl`, `subscriberCount`,
`status`(NEW|REGISTERED|EXCLUDED), 대표영상(`topVideoId`,`topVideoTitle`,`topVideoViewCount`,`ratio`),
`region`(발견 지역), `discoveredAt`, `updatedAt`(@CreationTimestamp/@UpdateTimestamp).
- Repo: `findByChannelId`, `findByStatusOrderByRatioDesc`, 존재여부 조회 등.
### 4.2 `ChannelDiscoveryService` (신규, `domain/channel` 또는 `service`)
- **검색 API 선택(구현 시 확정)**: 키워드 없는 "광범위 인기 Shorts"는 search.list(q 필요, 100units)보다
**videos.list `chart=mostPopular` + `regionCode`(1 unit, 키워드 불필요)** 가 적합하다. mostPopular 결과를
duration ≤ 60s(Shorts)로 필터링해 후보로 삼는다. 단 mostPopular 는 카테고리 제한이 있을 수 있어,
결과가 빈약하면 search.list(`order=viewCount`, `videoDuration=short`, 일반 q 또는 카테고리)로 보강한다.
`YoutubeSearchService` 에 region 기반 인기영상 조회 메서드를 추가하거나 신규 호출 경로를 둔다.
- `Map<String,Object> runDiscovery()`:
1. regions 별 인기 Shorts 조회(쿼터 가드로 지역마다 잔여 확인 후 진행).
2. 결과 영상 → 채널별 최고 배율 집계(`Map<channelId, Candidate>`).
3. 필터(구독자 상한·배율 하한·등록됨 제외·EXCLUDED 제외).
4. `RecommendedChannel` upsert(있으면 더 좋은 메트릭으로 갱신, status 가 NEW 일 때만).
5. 요약 반환(검색 지역수, 후보수, 신규 저장수, 소비 쿼터).
- 쿼터 부족 시 해당 지역 건너뛰고 로그(기존 패턴).
### 4.3 스케줄 연결
- `ScheduledCollectionService``@Scheduled(cron=${hlab.scheduler.channel-discovery.cron})` 추가 →
`channelDiscoveryService.runDiscovery()`. enabled 플래그로 on/off. 수동 실행용 메서드도 제공.
### 4.4 컨트롤러 (`RecommendedChannelController`, 신규)
- `GET /api/recommended-channels?limit=` → status=NEW, 배율 desc 상위 목록(ApiResponse).
- `POST /api/recommended-channels/{id}/register` → 내 채널 등록(Channel 생성) + status=REGISTERED.
- `POST /api/recommended-channels/{id}/exclude` → status=EXCLUDED.
- `POST /api/recommended-channels/run` → 수동 발굴 트리거(선택, 관리용).
### 4.5 웹 페이지 `/recommend` (신규 `recommend.html` + `WebController` 라우트)
- 사이드바에 "추천 채널" 메뉴 추가(`currentPage="recommend"`).
- 추천 채널 카드 그리드: 썸네일·채널명·구독자·대표 떡상영상(배율 배지)·YouTube 링크.
- 각 카드 [내 채널 등록]/[제외] 버튼 → 위 API 호출 후 목록 갱신.
- 빈 상태/로딩/사용법 안내(기존 페이지 톤 따름).
## 5. 설정 (`application.yml`, 기본값 有)
```yaml
hlab:
scheduler:
channel-discovery:
enabled: ${CHANNEL_DISCOVERY_ENABLED:true}
cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30 (기존 수집 04:00 이후)
discovery:
regions: ${DISCOVERY_REGIONS:KR,JP,US}
max-subscribers: ${DISCOVERY_MAX_SUBS:100000} # 작은 채널 상한
min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한
pages-per-region: ${DISCOVERY_PAGES:1} # 지역당 검색 페이지 수
top-n: ${DISCOVERY_TOP_N:30} # 추천 페이지 노출 상한
```
## 6. 쿼터/성능
- search.list = 지역·페이지당 약 100 units, channels.list(구독자) = 50채널당 1 unit.
KR,JP,US × 1페이지 ≈ 300 units + 수십 units → 일일 한도(기본 10000) 내 충분.
- `YoutubeQuotaGuard.remaining()` 확인 후 지역 단위로 진행/건너뜀.
## 7. 에러 처리
- 특정 지역 검색 실패 → 그 지역만 건너뛰고 나머지 진행(로그).
- 채널 등록 실패(이미 존재 등) → 사용자에게 메시지, 추천 항목은 REGISTERED 로 정리.
- 쿼터 소진 → 발굴 중단, 다음 주기 재시도.
## 8. 테스트 (`src/test`)
순수 로직 위주:
- 집계/랭킹: 영상 목록 → 채널별 최고 배율 선정, 구독자 상한·배율 하한 필터.
- dedup: 등록된 channelId·EXCLUDED 제외.
- 실제 YouTube/스케줄은 라이브/수동 검증.
## 9. 범위 제외 (YAGNI)
- 구독자 증가율 추적(스냅샷 누적 필요)
- 키워드 기반 검색(이번엔 지역 광범위)
- 자동 등록(수동 검토 유지)
- 추천 사유 다중 지표(이번엔 배율 단일 기준)

View File

@ -43,9 +43,6 @@ public class Channel {
@Column(name = "uploads_playlist_id") @Column(name = "uploads_playlist_id")
private String uploadsPlaylistId; private String uploadsPlaylistId;
@Column(length = 8)
private String country; // YouTube snippet.country (ISO-3166 alpha-2, : KR/JP/US). 없으면 null
private LocalDateTime publishedAt; private LocalDateTime publishedAt;
@CreatedDate @CreatedDate
@ -81,9 +78,5 @@ public class Channel {
this.uploadsPlaylistId = uploadsPlaylistId; this.uploadsPlaylistId = uploadsPlaylistId;
} }
public void setCountry(String country) {
this.country = country;
}
// Domain Logic methods here if needed // Domain Logic methods here if needed
} }

View File

@ -1,114 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
import com.hlab.yanalyst.service.YoutubeSearchService;
import com.hlab.yanalyst.web.dto.YoutubeSearchCondition;
import com.hlab.yanalyst.web.dto.YoutubeSearchPageDto;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelDiscoveryService {
/** search.list 1회 추정 쿼터(units). */
private static final long SEARCH_QUOTA = 100;
private final YoutubeSearchService youtubeSearchService;
private final YoutubeQuotaGuard quotaGuard;
private final ChannelRepository channelRepository;
private final RecommendedChannelRepository recommendedChannelRepository;
@Value("${hlab.discovery.regions:KR,JP,US}")
private String regionsCsv;
@Value("${hlab.discovery.max-subscribers:100000}")
private long maxSubscribers;
@Value("${hlab.discovery.min-ratio:5.0}")
private double minRatio;
@Value("${hlab.discovery.period-days:14}")
private int periodDays;
@Transactional
public Map<String, Object> runDiscovery() {
List<String> regions = Arrays.stream(regionsCsv.split(","))
.map(String::trim).filter(s -> !s.isBlank()).toList();
List<YoutubeSearchResultDto> all = new ArrayList<>();
List<String> searchedRegions = new ArrayList<>();
for (String region : regions) {
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
break;
}
try {
YoutubeSearchCondition cond = new YoutubeSearchCondition();
cond.setRegions(List.of(region));
cond.setFormat("SHORTS");
cond.setPeriodDays(periodDays);
cond.setOrder("viewCount"); // 떡상 발굴: 최신순이 아니라 조회수순
// 광범위 검색: 키워드 없이 인기 Shorts. 결과 빈약 검색 API 보강 필요(스펙 §4.2).
YoutubeSearchPageDto page = youtubeSearchService.searchYoutubeVideos(cond);
if (page != null && page.getItems() != null) {
// 발견 지역(검색 regionCode)으로 태깅 채널 국가가 아닌 '어느 지역 검색에서 떴나'
for (YoutubeSearchResultDto it : page.getItems()) it.setChannelCountry(region);
all.addAll(page.getItems());
}
searchedRegions.add(region);
} catch (Exception e) {
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
}
}
// 이미 제외(EXCLUDED) 채널 제외
Set<String> exclude = new HashSet<>();
for (RecommendedChannel rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) {
exclude.add(rc.getChannelId());
}
List<DiscoveryRanker.Candidate> ranked = DiscoveryRanker.rank(all, maxSubscribers, minRatio, exclude);
int saved = 0;
for (DiscoveryRanker.Candidate c : ranked) {
if (channelRepository.existsByChannelId(c.channelId())) continue; // 이미 채널
RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId())
.orElseGet(RecommendedChannel::new);
if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue;
// 기존 NEW 항목이면 낮은 배율일 때만 건너뜀(같거나 높으면 갱신 지역 메타 최신화)
if (rc.getId() != null && rc.getRatio() != null && c.ratio() < rc.getRatio()) continue;
rc.setChannelId(c.channelId());
rc.setChannelTitle(c.channelTitle());
rc.setThumbnailUrl(c.thumbnailUrl());
rc.setSubscriberCount(c.subscriberCount());
rc.setStatus("NEW");
rc.setTopVideoId(c.topVideoId());
rc.setTopVideoTitle(c.topVideoTitle());
rc.setTopVideoViewCount(c.topVideoViewCount());
rc.setRatio(c.ratio());
rc.setRegion(c.region());
recommendedChannelRepository.save(rc);
saved++;
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("regions", searchedRegions);
summary.put("candidates", ranked.size());
summary.put("saved", saved);
summary.put("quotaRemaining", quotaGuard.remaining());
log.info("[Discovery] 완료: {}", summary);
return summary;
}
}

View File

@ -7,7 +7,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -21,7 +20,6 @@ import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.io.File;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
@ -42,7 +40,6 @@ public class ChannelService {
private final ChannelVideoRepository channelVideoRepository; private final ChannelVideoRepository channelVideoRepository;
private final ChannelVideoScriptRepository channelVideoScriptRepository; private final ChannelVideoScriptRepository channelVideoScriptRepository;
private final ChannelSnapshotRepository channelSnapshotRepository; private final ChannelSnapshotRepository channelSnapshotRepository;
private final RecommendedChannelRepository recommendedChannelRepository;
@Value("${youtube.api.key}") // application.yml(youtube.api.key) 환경변수 YOUTUBE_API_KEY 오버라이드 @Value("${youtube.api.key}") // application.yml(youtube.api.key) 환경변수 YOUTUBE_API_KEY 오버라이드
private String youtubeApiKey; private String youtubeApiKey;
@ -90,7 +87,6 @@ public class ChannelService {
Long videoCount = Long.parseLong(statistics.get("videoCount").asText()); Long videoCount = Long.parseLong(statistics.get("videoCount").asText());
String uploadsPlaylistId = contentDetails.path("relatedPlaylists").path("uploads").asText(); String uploadsPlaylistId = contentDetails.path("relatedPlaylists").path("uploads").asText();
String country = snippet.path("country").asText(null); // ISO 국가코드(: KR/JP/US), 미설정 채널은 null
Channel channel = channelRepository.findByChannelId(channelId) Channel channel = channelRepository.findByChannelId(channelId)
.map(existingChannel -> { .map(existingChannel -> {
@ -137,9 +133,6 @@ public class ChannelService {
// But wait, I can't break the build. // But wait, I can't break the build.
// Let's implement the rest of the service methods. // Let's implement the rest of the service methods.
if (country != null && !country.isBlank()) {
channel.setCountry(country.toUpperCase());
}
Channel saved = channelRepository.save(channel); Channel saved = channelRepository.save(channel);
captureSnapshot(saved); // 성장 추이용 일별 스냅샷 기록(upsert) captureSnapshot(saved); // 성장 추이용 일별 스냅샷 기록(upsert)
return saved; return saved;
@ -150,27 +143,6 @@ public class ChannelService {
} }
} }
/**
* country 비어있는 기존 채널을, 추천 채널 테이블의 발견 지역(region: KR/JP/US)으로 채운다.
* (추천에서 등록한 채널은 검색 지역이 국가이므로 신뢰도가 높다. 매칭 없으면 그대로 둔다.)
* @return 백필된 채널
*/
@Transactional
public int backfillCountriesFromRecommend() {
int updated = 0;
for (Channel ch : channelRepository.findAll()) {
if (ch.getCountry() != null && !ch.getCountry().isBlank()) continue;
String region = recommendedChannelRepository.findByChannelId(ch.getChannelId())
.map(RecommendedChannel::getRegion).orElse(null);
if (region != null && !region.isBlank()) {
ch.setCountry(region.toUpperCase());
updated++;
}
}
log.info("국가 백필 완료: {}개 채널 갱신", updated);
return updated;
}
/** 채널 통계 스냅샷을 오늘 날짜로 upsert. */ /** 채널 통계 스냅샷을 오늘 날짜로 upsert. */
private void captureSnapshot(Channel channel) { private void captureSnapshot(Channel channel) {
java.time.LocalDate today = java.time.LocalDate.now(); java.time.LocalDate today = java.time.LocalDate.now();
@ -421,34 +393,19 @@ public class ChannelService {
*/ */
@Transactional @Transactional
public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) { public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) {
try {
return doTranscribe(channelVideoId, toFileResource(file), file.getSize(), language);
} catch (java.io.IOException e) {
throw new RuntimeException("업로드 파일을 읽지 못했습니다", e);
}
}
/** 다운로드 캐시 파일(yt-dlp 결과)을 Whisper 로 전사한다 — 업로드 없이 동일 파이프라인 재사용. */
@Transactional
public ScriptResponseDto transcribeFromCached(Long channelVideoId, File file, String language) {
return doTranscribe(channelVideoId, toFileResource(file), file.length(), language);
}
/** 전사 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */
private ScriptResponseDto doTranscribe(Long channelVideoId, Resource resource, long sizeBytes, String language) {
ChannelVideo video = channelVideoRepository.findById(channelVideoId) ChannelVideo video = channelVideoRepository.findById(channelVideoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId)); .orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
String apiUrl = pythonBaseUrl + "/transcribe"; String apiUrl = pythonBaseUrl + "/transcribe";
log.info("Requesting whisper transcription for video {} ({} bytes, lang={})", log.info("Requesting whisper transcription for video {} ({} bytes, lang={})",
channelVideoId, sizeBytes, language); channelVideoId, file.getSize(), language);
try { try {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource); body.add("file", toFileResource(file));
if (language != null && !language.isBlank()) { if (language != null && !language.isBlank()) {
body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja ) body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja )
} }
@ -461,20 +418,7 @@ public class ChannelService {
} }
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class); ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
persistScript(video, channelVideoId, dto);
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
return dto;
} catch (Exception e) {
log.error("Error transcribing uploaded file for video " + channelVideoId, e);
throw new RuntimeException("Error transcribing uploaded file", e);
}
}
/** 전사 결과 저장: 기존 스크립트 제거 후 새 스크립트 저장 + hasScript 승격. */
private void persistScript(ChannelVideo video, Long channelVideoId, ScriptResponseDto dto)
throws com.fasterxml.jackson.core.JsonProcessingException {
// 재추출 기존 스크립트(중복 포함) 먼저 제거해 videoId 1건만 유지한다. // 재추출 기존 스크립트(중복 포함) 먼저 제거해 videoId 1건만 유지한다.
channelVideoScriptRepository.deleteAll( channelVideoScriptRepository.deleteAll(
channelVideoScriptRepository.findAllByVideoId(video.getVideoId())); channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
@ -490,6 +434,14 @@ public class ChannelService {
video.setHasScript(true); video.setHasScript(true);
channelVideoRepository.save(video); channelVideoRepository.save(video);
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
return dto;
} catch (Exception e) {
log.error("Error transcribing uploaded file for video " + channelVideoId, e);
throw new RuntimeException("Error transcribing uploaded file", e);
}
} }
/** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */ /** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */
@ -526,20 +478,6 @@ public class ChannelService {
* keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다. * keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다.
*/ */
public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) { public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) {
try {
return doRender(channelVideoId, toFileResource(file), pad, minGap, speed);
} catch (java.io.IOException e) {
throw new RuntimeException("업로드 파일을 읽지 못했습니다", e);
}
}
/** 다운로드 캐시 파일(yt-dlp 결과)로 말 없는 구간 제거(+배속) 렌더 — 업로드 없이 동일 파이프라인 재사용. */
public byte[] renderTrimmedFromCached(Long channelVideoId, File file, double pad, double minGap, double speed) {
return doRender(channelVideoId, toFileResource(file), pad, minGap, speed);
}
/** 렌더 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */
private byte[] doRender(Long channelVideoId, Resource resource, double pad, double minGap, double speed) {
List<ScriptSegment> segments = getSegments(channelVideoId); List<ScriptSegment> segments = getSegments(channelVideoId);
if (segments.isEmpty()) { if (segments.isEmpty()) {
// 사용자에게 이유가 보이도록 400(IllegalArgumentException GlobalExceptionHandler 메시지 노출). // 사용자에게 이유가 보이도록 400(IllegalArgumentException GlobalExceptionHandler 메시지 노출).
@ -553,7 +491,7 @@ public class ChannelService {
headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource); body.add("file", toFileResource(file));
body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...] body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...]
body.add("speed", String.valueOf(speed)); body.add("speed", String.valueOf(speed));
@ -569,11 +507,6 @@ public class ChannelService {
} }
} }
/** File(다운로드 캐시) → multipart 전송용 Resource(파일명 보존, 디스크 스트리밍). */
private Resource toFileResource(File file) {
return new FileSystemResource(file);
}
/** MultipartFile → multipart 전송용 Resource(파일명 보존). */ /** MultipartFile → multipart 전송용 Resource(파일명 보존). */
private Resource toFileResource(MultipartFile file) throws java.io.IOException { private Resource toFileResource(MultipartFile file) throws java.io.IOException {
final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank()) final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank())

View File

@ -133,70 +133,6 @@ public class ChannelVideoCurationController {
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t)); return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
} }
@PostMapping("/{id}/download")
@Operation(summary = "원본 다운로드(캐시만)",
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). "
+ "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. "
+ "응답: {downloaded, sizeBytes}. 이후 전사·렌더가 이 캐시 파일을 재사용한다.")
public ApiResponse<Map<String, Object>> download(@PathVariable Long id) {
return ApiResponse.ok(curationService.downloadOriginal(id));
}
@PostMapping("/{id}/transcribe-cached")
@Operation(summary = "받은 원본 전사(Whisper)",
description = "'원본 다운로드'로 받아둔 캐시 파일을 Whisper 로 전사해 세그먼트를 저장한다(다운로드와 분리). "
+ "쿼리: language(선택, 비우면 자동감지). 응답: {hasScript, language, duration, transcript, segments}.")
public ApiResponse<Map<String, Object>> transcribeCached(@PathVariable Long id,
@RequestParam(value = "language", required = false) String language) {
return ApiResponse.ok(curationService.transcribeCached(id, language));
}
@GetMapping("/{id}/download")
@Operation(summary = "받은 원본 상태", description = "원본 다운로드 캐시 보유 여부와 용량을 반환한다. 응답: {cached, sizeBytes}.")
public ApiResponse<Map<String, Object>> downloadStatus(@PathVariable Long id) {
return ApiResponse.ok(curationService.downloadStatus(id));
}
@DeleteMapping("/{id}/download")
@Operation(summary = "받은 원본 삭제", description = "원본 다운로드로 받아둔 캐시 mp4를 삭제한다(자막/세그먼트는 유지). 응답: {deleted}.")
public ApiResponse<Map<String, Object>> deleteDownload(@PathVariable Long id) {
boolean deleted = curationService.deleteDownloadCache(id);
return ApiResponse.ok(Map.of("deleted", deleted));
}
@GetMapping("/{id}/download/file")
@Operation(summary = "받은 원본 스트리밍",
description = "받아둔 원본 mp4를 왼쪽 플레이어 재생용으로 스트리밍한다. HTTP Range(구간요청) 지원으로 seek 가능. "
+ "세그먼트(타임라인) 클릭 시 이 영상이 그 지점으로 이동/재생된다. 캐시 없으면 400.")
public ResponseEntity<org.springframework.core.io.Resource> downloadFile(@PathVariable Long id) {
java.io.File file = curationService.cachedDownloadFile(id);
org.springframework.core.io.Resource resource = new org.springframework.core.io.FileSystemResource(file);
return ResponseEntity.ok()
.contentType(new MediaType("video", "mp4"))
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.body(resource); // Resource 반환 Spring Range 요청을 자동으로 206 처리
}
@PostMapping("/{id}/gemini-subtitles")
@Operation(summary = "화면 자막(Gemini, 한국어)",
description = "저장된 유튜브 URL을 Gemini에 보내 화면에 박힌 자막을 추출·한국어 번역해 세그먼트로 반환한다(저장 안 함). "
+ "음성 자막은 전사+번역(왼쪽)이 담당. 응답: {segments:[{start,end,text(한국어)}]}. "
+ "타임스탬프는 Gemini 특성상 ~1초 근사.")
public ApiResponse<Map<String, Object>> geminiSubtitles(@PathVariable Long id) {
return ApiResponse.ok(curationService.geminiScreenSubtitles(id));
}
@PostMapping("/{id}/translate")
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
+ "쿼리: target(기본 ko), format(plain=평문 흐름 | timeline=세그먼트별 '[mm:ss] 번역' 줄). "
+ "소스 언어는 전사 언어로 자동. 응답: {translatedText, source, target, format}.")
public ApiResponse<Map<String, Object>> translate(@PathVariable Long id,
@RequestParam(value = "target", required = false) String target,
@RequestParam(value = "format", required = false) String format) {
return ApiResponse.ok(curationService.translateScript(id, target, format));
}
@PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "업로드 영상 전사(Whisper)", @Operation(summary = "업로드 영상 전사(Whisper)",
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. " description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
@ -220,17 +156,14 @@ public class ChannelVideoCurationController {
@PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더", @Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더",
description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. " description = "업로드 영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
+ "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 " + "multipart: file, pad, minGap, speed. keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
+ "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
public ResponseEntity<byte[]> render(@PathVariable Long id, public ResponseEntity<byte[]> render(@PathVariable Long id,
@RequestParam(value = "file", required = false) MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "0.15") double pad, @RequestParam(defaultValue = "0.15") double pad,
@RequestParam(defaultValue = "0.3") double minGap, @RequestParam(defaultValue = "0.3") double minGap,
@RequestParam(defaultValue = "1.0") double speed) { @RequestParam(defaultValue = "1.0") double speed) {
byte[] mp4 = (file != null && !file.isEmpty()) byte[] mp4 = curationService.renderTrimmed(id, file, pad, minGap, speed);
? curationService.renderTrimmed(id, file, pad, minGap, speed)
: curationService.renderTrimmedFromCached(id, pad, minGap, speed);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType("video", "mp4")); headers.setContentType(new MediaType("video", "mp4"));
headers.setContentDisposition( headers.setContentDisposition(

View File

@ -24,9 +24,6 @@ public class ChannelVideoCurationService {
private final ChannelVideoRepository channelVideoRepository; private final ChannelVideoRepository channelVideoRepository;
private final ChannelVideoScriptRepository channelVideoScriptRepository; private final ChannelVideoScriptRepository channelVideoScriptRepository;
private final ChannelService channelService; private final ChannelService channelService;
private final VideoDownloadService videoDownloadService;
private final com.hlab.yanalyst.service.TranslateService translateService;
private final com.hlab.yanalyst.service.GeminiSubtitleService geminiSubtitleService;
private static final Set<String> ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED"); private static final Set<String> ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED");
private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec"); private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
@ -124,144 +121,6 @@ public class ChannelVideoCurationService {
return result; return result;
} }
/**
* 저장된 videoId 원본을 yt-dlp 받아 서버에 캐시만 한다(빠름). 전사는 분리된 단계로 진행.
* 전사 서버가 느리거나 실패해도 다운로드 결과(영상) 잃지 않는다.
*/
@Transactional
public Map<String, Object> downloadOriginal(Long videoId) {
ChannelVideo v = find(videoId);
java.io.File file = videoDownloadService.download(v.getId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("downloaded", true);
result.put("sizeBytes", file.length());
return result;
}
/**
* 받아둔 원본 캐시 파일을 Whisper 전사한다(다운로드와 분리된 단계). language=null 이면 자동감지.
* 캐시 없으면 안내 예외.
*/
@Transactional
public Map<String, Object> transcribeCached(Long videoId, String language) {
ChannelVideo v = find(videoId);
java.io.File file = videoDownloadService.cachedFile(v.getId())
.orElseThrow(() -> new IllegalArgumentException(
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language);
Map<String, Object> result = new LinkedHashMap<>();
result.put("hasScript", true);
result.put("language", dto.getLanguage());
result.put("duration", dto.getDuration());
result.put("transcript", dto.getTranscript() == null ? "" : dto.getTranscript());
result.put("segments", channelService.getSegments(v.getId()));
return result;
}
/** 다운로드 캐시 상태(보유 여부 + 용량). 재가공 진입 시 '받은 원본 삭제' 버튼 표시/렌더 캐시 사용 판단에 쓴다. */
public Map<String, Object> downloadStatus(Long videoId) {
ChannelVideo v = find(videoId);
java.util.Optional<java.io.File> cached = videoDownloadService.cachedFile(v.getId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("cached", cached.isPresent());
result.put("sizeBytes", cached.map(java.io.File::length).orElse(0L));
return result;
}
/** 재가공 화면 '받은 원본 삭제' — 다운로드 캐시 mp4 삭제(자막/세그먼트는 유지). */
public boolean deleteDownloadCache(Long videoId) {
ChannelVideo v = find(videoId);
return videoDownloadService.deleteCache(v.getId());
}
/** 받아둔 원본 mp4 파일(왼쪽 플레이어 재생용 스트리밍). 캐시 없으면 안내 예외. */
public java.io.File cachedDownloadFile(Long videoId) {
ChannelVideo v = find(videoId);
return videoDownloadService.cachedFile(v.getId())
.orElseThrow(() -> new IllegalArgumentException(
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
}
/**
* 유튜브 URL을 Gemini에 보내 <b>화면 자막</b> 한국어 세그먼트로 추출한다(저장 표시·다운로드용).
* 음성 자막은 기존 전사+번역(왼쪽) 담당하므로 여기선 화면(오른쪽).
*/
public Map<String, Object> geminiScreenSubtitles(Long videoId) {
ChannelVideo v = find(videoId);
List<ScriptResponseDto.Segment> segments =
geminiSubtitleService.fetchKoreanScreenSubtitles(v.getVideoId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("segments", segments);
return result;
}
/** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */
public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) {
ChannelVideo v = find(videoId);
java.io.File file = videoDownloadService.cachedFile(v.getId())
.orElseThrow(() -> new IllegalArgumentException(
"원본 영상이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
return channelService.renderTrimmedFromCached(v.getId(), file, pad, minGap, speed);
}
/**
* 원본 스크립트를 번역해 재가공( 버전) 초안용 텍스트로 반환한다.
* format="timeline" 이면 세그먼트별 "[mm:ss] 번역" 줄로, (plain) 전사 평문 흐름으로 번역.
* source 전사 언어(없으면 auto), target 기본 ko. 저장은 사용자가 검토 직접.
*/
public Map<String, Object> translateScript(Long videoId, String target, String format) {
ChannelVideo v = find(videoId);
var scriptOpt = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId());
String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null);
String tgt = (target == null || target.isBlank()) ? "ko" : target.trim();
boolean timeline = "timeline".equalsIgnoreCase(format);
boolean segmentsMode = "segments".equalsIgnoreCase(format); // SRT용: 세그먼트별 번역 배열 반환
Map<String, Object> result = new LinkedHashMap<>();
result.put("source", source);
result.put("target", tgt);
if (timeline || segmentsMode) {
List<ScriptSegment> segments = channelService.getSegments(v.getId());
if (segments.isEmpty()) {
throw new IllegalArgumentException("타임라인(시간 싱크 세그먼트)이 없습니다. 먼저 원본 다운로드/업로드로 전사하세요.");
}
List<String> texts = segments.stream().map(ScriptSegment::text).toList();
List<String> ko = translateService.translateBatch(texts, source, tgt);
if (segmentsMode) {
// 입력 세그먼트와 1:1 정렬된 번역 배열(프론트가 기존 SRT 로직에 끼워 넣음)
result.put("format", "segments");
result.put("texts", ko);
} else {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < segments.size(); i++) {
String line = (i < ko.size()) ? ko.get(i) : "";
sb.append('[').append(mmss(segments.get(i).start())).append("] ")
.append(line == null ? "" : line.trim()).append('\n');
}
result.put("format", "timeline");
result.put("translatedText", sb.toString().stripTrailing());
}
} else {
String text = scriptOpt.map(ChannelVideoScript::getTranscript).orElse(null);
if (text == null || text.isBlank()) {
throw new IllegalArgumentException("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요.");
}
result.put("format", "plain");
result.put("translatedText", translateService.translate(text, source, tgt));
}
return result;
}
/** 초 → mm:ss (타임라인 줄 머리표시용). */
private static String mmss(double seconds) {
int total = (int) Math.max(0, Math.floor(seconds));
return String.format("%02d:%02d", total / 60, total % 60);
}
/** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */ /** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
public String buildSrt(Long videoId, double speed) { public String buildSrt(Long videoId, double speed) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);
@ -304,13 +163,12 @@ public class ChannelVideoCurationService {
return v; return v;
} }
/** 수집함에서 영상 제거(연결된 스크립트 + 다운로드 캐시도 함께 삭제). */ /** 수집함에서 영상 제거(연결된 스크립트도 함께 삭제). */
@Transactional @Transactional
public void delete(Long videoId) { public void delete(Long videoId) {
ChannelVideo video = find(videoId); ChannelVideo video = find(videoId);
channelVideoScriptRepository.deleteAll( channelVideoScriptRepository.deleteAll(
channelVideoScriptRepository.findAllByVideoId(video.getVideoId())); channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
videoDownloadService.deleteCache(video.getId()); // 고아 mp4 방지
channelVideoRepository.delete(video); channelVideoRepository.delete(video);
} }

View File

@ -1,45 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 검색된 Shorts 목록 → 채널별 최고 배율 후보 집계·필터·정렬(순수 로직). */
public final class DiscoveryRanker {
private DiscoveryRanker() {}
public record Candidate(String channelId, String channelTitle, String thumbnailUrl, Long subscriberCount,
String topVideoId, String topVideoTitle, Long topVideoViewCount,
double ratio, String region) {}
public static List<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers,
double minRatio, Set<String> excludeChannelIds) {
Map<String, Candidate> best = new LinkedHashMap<>();
for (YoutubeSearchResultDto it : items) {
String ch = it.getChannelId();
Long subs = it.getSubscriberCount();
Long views = it.getViewCount();
if (ch == null || subs == null || subs <= 0 || views == null) continue;
if (excludeChannelIds.contains(ch)) continue;
if (subs > maxSubscribers) continue;
double ratio = (double) views / subs;
if (ratio < minRatio) continue;
Candidate prev = best.get(ch);
if (prev == null || ratio > prev.ratio()) {
best.put(ch, new Candidate(ch, it.getChannelTitle(), it.getThumbnailUrl(), subs,
it.getVideoId(), it.getTitle(), views, ratio, it.getChannelCountry()));
}
}
List<Candidate> out = new ArrayList<>(best.values());
out.sort(Comparator.comparingDouble(Candidate::ratio).reversed());
return out;
}
}

View File

@ -1,52 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
/** 자동 발굴된 추천 채널(떡상 Shorts 기반). status: NEW|REGISTERED|EXCLUDED. */
@Entity
@Table(name = "recommended_channels")
@Getter
@Setter
@NoArgsConstructor
public class RecommendedChannel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String channelId;
private String channelTitle;
@Column(length = 2083)
private String thumbnailUrl; // 대표 떡상 영상의 썸네일
private Long subscriberCount;
@Column(nullable = false)
private String status = "NEW";
// 대표(최고 배율) 영상
private String topVideoId;
@Column(length = 500)
private String topVideoTitle;
private Long topVideoViewCount;
private Double ratio; // topVideo viewCount / subscriberCount
private String region; // 발견 지역(KR/JP/US)
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime discoveredAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}

View File

@ -1,68 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/recommended-channels")
@RequiredArgsConstructor
@Tag(name = "Recommended Channel API", description = "자동 발굴된 추천 채널(떡상 Shorts)")
public class RecommendedChannelController {
private final RecommendedChannelRepository repository;
private final ChannelDiscoveryService discoveryService;
private final ChannelService channelService;
@GetMapping
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status) {
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc(status));
}
@PostMapping("/{id}/register")
@Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리")
@Transactional
public ApiResponse<Void> register(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
rc.setStatus("REGISTERED");
repository.save(rc);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/exclude")
@Operation(summary = "추천 제외", description = "다음 발굴에서 다시 뜨지 않게 EXCLUDED 처리")
@Transactional
public ApiResponse<Void> exclude(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
rc.setStatus("EXCLUDED");
repository.save(rc);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/restore")
@Operation(summary = "제외 복원", description = "EXCLUDED 를 다시 NEW(추천 목록)로 되돌린다")
@Transactional
public ApiResponse<Void> restore(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
rc.setStatus("NEW");
repository.save(rc);
return ApiResponse.ok(null);
}
@PostMapping("/run")
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
public ApiResponse<Map<String, Object>> run() {
return ApiResponse.ok(discoveryService.runDiscovery());
}
}

View File

@ -1,12 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface RecommendedChannelRepository extends JpaRepository<RecommendedChannel, Long> {
Optional<RecommendedChannel> findByChannelId(String channelId);
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
boolean existsByChannelIdAndStatus(String channelId, String status);
}

View File

@ -1,167 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
* 저장된 {@code videoId} yt-dlp <b>로컬에서 직접 실행(ProcessBuilder)</b> 원본 영상을 받아
* {@code {download.dir}/{channelVideoId}.mp4} 캐시한다. 받은 파일은 기존 Python 파이프라인
* ({@code /transcribe}, {@code /render}) 그대로 투입된다(전사·렌더 양쪽에서 캐시 재사용).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoDownloadService {
/** YouTube videoId 는 정확히 11자의 [A-Za-z0-9_-]. 셸 인젝션·오입력 차단용. */
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
private final ChannelVideoRepository channelVideoRepository;
@Value("${ytdlp.bin:yt-dlp}")
private String ytdlpBin;
@Value("${ytdlp.ffmpeg-location:}")
private String ffmpegLocation;
@Value("${ytdlp.max-height:1080}")
private int maxHeight;
@Value("${ytdlp.timeout-seconds:600}")
private long timeoutSeconds;
@Value("${download.dir:downloads}")
private String downloadDir;
/**
* 저장된 videoId 영상을 받아 {@code {download.dir}/{channelVideoId}.mp4} 저장하고 File 반환한다.
* 셸을 거치지 않고 인자 리스트를 ProcessBuilder 전달한다(인젝션 차단).
*/
public File download(Long channelVideoId) {
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
String videoId = video.getVideoId();
try {
Path dir = Path.of(downloadDir);
Files.createDirectories(dir);
Path out = dir.resolve(channelVideoId + ".mp4");
List<String> cmd = buildCommand(ytdlpBin, ffmpegLocation, maxHeight, videoId, out);
log.info("yt-dlp 다운로드 시작: videoId={} -> {}", videoId, out);
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
Process process = pb.start();
// 출력을 별도 스레드로 흡수(버퍼 막힘 방지 + waitFor 타임아웃이 실제로 동작하도록).
StringBuilder outBuf = new StringBuilder();
Thread drainer = new Thread(() -> {
try (BufferedReader r = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
synchronized (outBuf) { outBuf.append(line).append('\n'); }
}
} catch (IOException ignored) { /* 프로세스 종료 시 정상 */ }
});
drainer.setDaemon(true);
drainer.start();
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new RuntimeException("yt-dlp 다운로드 타임아웃(" + timeoutSeconds + "s)");
}
drainer.join(2000);
int code = process.exitValue();
if (code != 0) {
String output = outBuf.toString();
String tail = output.length() > 600 ? output.substring(output.length() - 600) : output;
throw new RuntimeException("yt-dlp 다운로드 실패(exit " + code + "): " + tail.strip());
}
File file = out.toFile();
if (!file.exists() || file.length() == 0) {
throw new RuntimeException("다운로드 결과 파일이 비어있습니다: " + out);
}
log.info("yt-dlp 다운로드 완료: {} ({} bytes)", out, file.length());
return file;
} catch (IOException e) {
throw new RuntimeException("yt-dlp 실행 실패 (설치/PATH 확인): " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("yt-dlp 다운로드가 중단되었습니다", e);
}
}
/** 캐시된 다운로드 파일(있고 비어있지 않을 때만). 렌더가 업로드 없이 재사용한다. */
public Optional<File> cachedFile(Long channelVideoId) {
File file = Path.of(downloadDir).resolve(channelVideoId + ".mp4").toFile();
return (file.exists() && file.length() > 0) ? Optional.of(file) : Optional.empty();
}
/** 다운로드 캐시 존재 여부. */
public boolean isCached(Long channelVideoId) {
return cachedFile(channelVideoId).isPresent();
}
/** 캐시된 다운로드 파일 삭제. 존재해서 지웠으면 true, 원래 없었으면 false. */
public boolean deleteCache(Long channelVideoId) {
File file = Path.of(downloadDir).resolve(channelVideoId + ".mp4").toFile();
if (!file.exists()) {
return false;
}
boolean ok = file.delete();
if (ok) {
log.info("다운로드 캐시 삭제: {}", file);
} else {
log.warn("다운로드 캐시 삭제 실패(파일 잠김?): {}", file);
}
return ok;
}
/**
* yt-dlp 명령 인자 리스트를 조립한다(프로세스 실행 없음 단위테스트 대상).
* videoId {@code [A-Za-z0-9_-]{11}} 형식만 허용한다.
*/
static List<String> buildCommand(String ytdlpBin, String ffmpegLocation, int maxHeight,
String videoId, Path outPath) {
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
}
String fmt = "bv*[height<=" + maxHeight + "]+ba/b[height<=" + maxHeight + "]/b";
List<String> cmd = new ArrayList<>();
cmd.add(ytdlpBin);
cmd.add("--no-playlist");
cmd.add("--force-overwrites");
cmd.add("-f");
cmd.add(fmt);
cmd.add("--merge-output-format");
cmd.add("mp4");
if (ffmpegLocation != null && !ffmpegLocation.isBlank()) {
cmd.add("--ffmpeg-location");
cmd.add(ffmpegLocation.trim());
}
cmd.add("-o");
cmd.add(outPath.toString());
cmd.add("https://www.youtube.com/watch?v=" + videoId);
return cmd;
}
}

View File

@ -24,9 +24,5 @@ public class ScriptResponseDto {
private double start; private double start;
private double end; private double end;
private String text; private String text;
/** 화면 자막(Gemini): 번역 전 원문. 음성 전사엔 없으므로 null 이면 직렬화 생략. */
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
private String original;
} }
} }

View File

@ -34,15 +34,4 @@ public class RestTemplateConfig {
.readTimeout(Duration.ofMinutes(10)) .readTimeout(Duration.ofMinutes(10))
.build(); .build();
} }
/**
* Gemini(유튜브 URL 영상 이해) RestTemplate. 영상 분석은 수십 ~ 걸릴 있어 read timeout 길게 둔다.
*/
@Bean
public RestTemplate geminiRestTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(20))
.readTimeout(Duration.ofMinutes(5))
.build();
}
} }

View File

@ -25,7 +25,6 @@ public class ScheduledCollectionService {
private final ChannelRepository channelRepository; private final ChannelRepository channelRepository;
private final ChannelService channelService; private final ChannelService channelService;
private final YoutubeQuotaGuard quotaGuard; private final YoutubeQuotaGuard quotaGuard;
private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService;
@Value("${hlab.scheduler.channel-collection.enabled:true}") @Value("${hlab.scheduler.channel-collection.enabled:true}")
private boolean enabled; private boolean enabled;
@ -33,20 +32,6 @@ public class ScheduledCollectionService {
@Value("${hlab.scheduler.channel-snapshot.enabled:true}") @Value("${hlab.scheduler.channel-snapshot.enabled:true}")
private boolean snapshotEnabled; private boolean snapshotEnabled;
@Value("${hlab.scheduler.channel-discovery.enabled:true}")
private boolean discoveryEnabled;
/** 추천 채널 발굴(지역 인기 Shorts → 떡상 채널). */
@Scheduled(cron = "${hlab.scheduler.channel-discovery.cron:0 30 4 * * *}")
public void scheduledDiscovery() {
if (!discoveryEnabled) {
log.info("[Scheduler] 추천 채널 발굴 비활성화됨 (hlab.scheduler.channel-discovery.enabled=false)");
return;
}
Map<String, Object> result = channelDiscoveryService.runDiscovery();
log.info("[Scheduler] 추천 채널 발굴 완료: {}", result);
}
/** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */ /** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */
private static final long EST_UNITS_PER_CHANNEL = 12; private static final long EST_UNITS_PER_CHANNEL = 12;

View File

@ -1,167 +0,0 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
/**
* Gemini(무료 티어) 유튜브 URL을 직접 보내 <b>화면에 박힌 자막</b> 추출·한국어 번역해 세그먼트로 받는다.
* 음성은 Whisper(정밀 타임스탬프) 담당하므로 여기선 화면 자막만 다룬다.
* 타임스탬프는 Gemini 특성상 ~1초 근사.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GeminiSubtitleService {
private static final String PROMPT =
"이 영상을 처음부터 끝까지 분석해서, 화면에 표시되는(영상에 박힌) 모든 자막을 빠짐없이 추출하세요. "
+ "음성/말소리는 무시하고 화면의 글자만 대상으로 합니다. "
+ "각 자막이 화면에 나타나는 시작 시각과 사라지는 끝 시각을 '영상 시작 기준 초'로 최대한 정확히 적으세요(예: 0, 2.5, 8.1). "
+ "중요: 모든 항목의 시간을 0으로 두지 마세요. 자막이 바뀌는 순서대로 start 가 증가해야 합니다. "
+ "각 자막의 화면 원문(그대로)과 한국어 번역을 함께 주세요. "
+ "JSON 배열로만 출력: [{\"start\": 시작초, \"end\": 끝초, \"original\": \"화면 원문 그대로\", \"text\": \"한국어 번역\"}]. "
+ "original 은 화면에 보이는 원문 그대로(번역하지 말 것). "
+ "text 는 그 원문을 반드시 '한국어'로 번역한 결과입니다 — 영어가 아니라 한국어로 쓰세요. "
+ "화면 자막이 전혀 없으면 빈 배열 [].";
private final RestTemplate geminiRestTemplate;
private final ObjectMapper objectMapper;
@Value("${gemini.api-key:}")
private String apiKey;
@Value("${gemini.model:gemini-2.5-flash}")
private String model;
/**
* 유튜브 영상의 화면 자막을 한국어 세그먼트로 반환.
* Gemini 영상이해는 비결정적이라 결과가 나올 있어, 0개면 최대 3회 재시도한다(에러는 즉시 전파).
*/
public List<ScriptResponseDto.Segment> fetchKoreanScreenSubtitles(String videoId) {
for (int attempt = 1; attempt <= 3; attempt++) {
List<ScriptResponseDto.Segment> segs = callGeminiOnce(videoId);
if (!segs.isEmpty()) return segs;
log.info("Gemini 화면자막 0개(video {}, {}/3) — 재시도", videoId, attempt);
}
return List.of();
}
/** Gemini 단일 호출(화면 자막 → 한국어 세그먼트). HTTP/키 오류는 예외로 전파, 정상이면 세그먼트(빈 가능). */
private List<ScriptResponseDto.Segment> callGeminiOnce(String videoId) {
String key = sanitizeApiKey(apiKey);
if (key.isEmpty()) {
throw new IllegalArgumentException("GEMINI_API_KEY 가 설정되지 않았습니다.");
}
String videoUrl = "https://www.youtube.com/watch?v=" + videoId;
String apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/"
+ model + ":generateContent?key=" + key;
log.info("Requesting Gemini screen-subtitles for video {} (model={})", videoId, model);
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(buildRequest(videoUrl, PROMPT), headers);
ResponseEntity<String> response = geminiRestTemplate.postForEntity(apiUrl, request, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("Gemini API 실패: " + response.getStatusCode());
}
List<ScriptResponseDto.Segment> segs = extractSegments(objectMapper, response.getBody());
log.info("Gemini screen-subtitles for video {}: {} segments", videoId, segs.size());
return segs;
} catch (HttpStatusCodeException e) {
// 키는 URL 쿼리에 있으므로 메시지에 apiUrl 넣지 않는다(노출 방지).
int code = e.getStatusCode().value();
String body = e.getResponseBodyAsString();
String msg;
if (code == 403) {
msg = "Gemini 접근 거부(403): 이 API 키의 프로젝트가 차단되었습니다. 다른 프로젝트의 새 API 키가 필요합니다.";
} else if (code == 429) {
msg = "Gemini 무료 한도 초과(429): 잠시 후 다시 시도하세요.";
} else if (code == 400 && body.contains("API key not valid")) {
msg = "Gemini API 키가 유효하지 않습니다(GEMINI_API_KEY 값을 확인하세요).";
} else {
String tail = body.length() > 200 ? body.substring(0, 200) : body;
msg = "Gemini 호출 실패(" + code + "): " + tail;
}
log.error("Gemini 호출 실패({}) for video {}", code, videoId);
throw new IllegalArgumentException(msg, e); // GlobalExceptionHandler 메시지를 그대로 노출
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Gemini 자막 추출 실패: " + e.getMessage(), e);
}
}
/** API 키 정리(순수): 앞뒤 공백 + 잘못 붙은 선행 '=' 제거. 유효한 키는 이런 문자로 시작하지 않는다. */
static String sanitizeApiKey(String raw) {
if (raw == null) return "";
String k = raw.strip();
while (k.startsWith("=")) {
k = k.substring(1).strip();
}
return k;
}
/** Gemini generateContent 요청 바디 조립(순수). 유튜브 URL + 프롬프트 + JSON 응답 강제. */
static Map<String, Object> buildRequest(String videoUrl, String prompt) {
Map<String, Object> textPart = Map.of("text", prompt);
Map<String, Object> filePart = Map.of("file_data", Map.of("file_uri", videoUrl));
Map<String, Object> content = Map.of("parts", List.of(textPart, filePart));
Map<String, Object> itemSchema = Map.of(
"type", "OBJECT",
"properties", Map.of(
"start", Map.of("type", "NUMBER"),
"end", Map.of("type", "NUMBER"),
"original", Map.of("type", "STRING"),
"text", Map.of("type", "STRING")));
// Gemini 2.5 Flash 'thinking' 모델: 정확한 타임스탬프는 thinking 으로 추론한다(끄면 시간이 전부 0).
// thinking 토큰이 출력 한도를 먹어 JSON 잘리면 0개가 되므로 maxOutputTokens 크게 준다.
Map<String, Object> genConfig = Map.of(
"responseMimeType", "application/json",
"temperature", 0,
"maxOutputTokens", 65536,
"responseSchema", Map.of("type", "ARRAY", "items", itemSchema));
return Map.of("contents", List.of(content), "generationConfig", genConfig);
}
/**
* Gemini 응답에서 세그먼트 배열을 추출(순수). candidatescontentpartstext 안의 JSON 배열을 파싱한다.
* 코드펜스나 잡텍스트로 감싸여도 '[' ~ 마지막 ']' 구간을 잘라 파싱한다. 실패/없음이면 리스트.
*/
static List<ScriptResponseDto.Segment> extractSegments(ObjectMapper objectMapper, String responseJson) {
try {
JsonNode parts = objectMapper.readTree(responseJson)
.path("candidates").path(0).path("content").path("parts");
StringBuilder sb = new StringBuilder();
if (parts.isArray()) {
for (JsonNode p : parts) sb.append(p.path("text").asText(""));
}
String text = sb.toString();
int lb = text.indexOf('['), rb = text.lastIndexOf(']');
if (lb < 0 || rb <= lb) return List.of();
List<ScriptResponseDto.Segment> segs = objectMapper.readValue(
text.substring(lb, rb + 1),
objectMapper.getTypeFactory().constructCollectionType(List.class, ScriptResponseDto.Segment.class));
return segs == null ? List.of() : segs;
} catch (Exception e) {
return List.of();
}
}
}

View File

@ -1,126 +0,0 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 자가호스팅 LibreTranslate 연동. 원본 스크립트를 번역해 재가공( 버전) 초안으로 쓴다.
* API: {@code POST /translate {q, source, target}} {@code {"translatedText": "..."}}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TranslateService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Value("${translate.base-url:https://h-translate.tolag.shop}")
private String baseUrl;
/** text 를 source→target 으로 번역해 평문을 반환. source 가 비면 자동감지("auto"). */
public String translate(String text, String source, String target) {
if (text == null || text.isBlank()) {
return "";
}
String apiUrl = baseUrl + "/translate";
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> request =
new HttpEntity<>(buildRequestBody(text, source, target), headers);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("Translate API 실패: " + response.getStatusCode());
}
return extractTranslatedText(objectMapper, response.getBody());
} catch (Exception e) {
log.error("번역 실패 (source={}, target={}, len={})", source, target,
text.length(), e);
throw new RuntimeException("번역 실패: " + e.getMessage(), e);
}
}
/**
* 여러 텍스트를 번에 번역(LibreTranslate q 배열로 받으면 정렬 맞춰 배열로 반환).
* 세그먼트별 번역에 사용해 입력 순서와 1:1 정렬을 보장한다.
*/
public java.util.List<String> translateBatch(java.util.List<String> texts, String source, String target) {
if (texts == null || texts.isEmpty()) {
return java.util.Collections.emptyList();
}
String apiUrl = baseUrl + "/translate";
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> request =
new HttpEntity<>(buildBatchRequestBody(texts, source, target), headers);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("Translate API 실패: " + response.getStatusCode());
}
return extractTranslatedTexts(objectMapper, response.getBody());
} catch (Exception e) {
log.error("배치 번역 실패 (source={}, target={}, n={})", source, target, texts.size(), e);
throw new RuntimeException("번역 실패: " + e.getMessage(), e);
}
}
/** LibreTranslate /translate 요청 바디 조립(순수). source 빈값/null → "auto". */
static Map<String, String> buildRequestBody(String text, String source, String target) {
Map<String, String> body = new LinkedHashMap<>();
body.put("q", text);
body.put("source", (source == null || source.isBlank()) ? "auto" : source.trim());
body.put("target", target);
body.put("format", "text");
return body;
}
/** 배치 요청 바디 조립(순수). q 를 배열로 담는다. source 빈값/null → "auto". */
static Map<String, Object> buildBatchRequestBody(java.util.List<String> texts, String source, String target) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("q", texts);
body.put("source", (source == null || source.isBlank()) ? "auto" : source.trim());
body.put("target", target);
body.put("format", "text");
return body;
}
/** 응답 JSON 에서 translatedText 추출(순수). 필드 없으면 빈 문자열, 파싱 실패 시 예외. */
static String extractTranslatedText(ObjectMapper objectMapper, String json) {
try {
return objectMapper.readTree(json).path("translatedText").asText("");
} catch (Exception e) {
throw new RuntimeException("번역 응답 파싱 실패", e);
}
}
/** 응답의 translatedText 를 리스트로 추출(순수). 배열이면 그대로, 문자열이면 단일 원소. */
static java.util.List<String> extractTranslatedTexts(ObjectMapper objectMapper, String json) {
try {
com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json).path("translatedText");
java.util.List<String> out = new java.util.ArrayList<>();
if (node.isArray()) {
node.forEach(n -> out.add(n.asText("")));
} else {
out.add(node.asText(""));
}
return out;
} catch (Exception e) {
throw new RuntimeException("번역 응답 파싱 실패", e);
}
}
}

View File

@ -59,9 +59,7 @@ public class YoutubeSearchService {
List<JsonNode> allItems = new ArrayList<>(); List<JsonNode> allItems = new ArrayList<>();
for (String target : searchTargets) { for (String target : searchTargets) {
String order = StringUtils.hasText(condition.getOrder()) String order = StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date";
? condition.getOrder()
: (StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date");
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(searchApiUrl) UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(searchApiUrl)
.queryParam("part", "snippet") .queryParam("part", "snippet")

View File

@ -34,10 +34,4 @@ public class ChannelApiController {
channelService.deleteChannel(id); channelService.deleteChannel(id);
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
@PostMapping("/backfill-country")
@Operation(summary = "Backfill country", description = "추천 채널의 발견 지역으로 기존 채널의 국가를 채운다")
public ApiResponse<Integer> backfillCountry() {
return ApiResponse.ok(channelService.backfillCountriesFromRecommend());
}
} }

View File

@ -52,12 +52,6 @@ public class WebController {
return "discover"; return "discover";
} }
@GetMapping("/recommend")
public String recommend(Model model) {
model.addAttribute("currentPage", "recommend");
return "recommend";
}
@GetMapping("/publish") @GetMapping("/publish")
public String publish(Model model) { public String publish(Model model) {
model.addAttribute("currentPage", "publish"); model.addAttribute("currentPage", "publish");

View File

@ -11,5 +11,4 @@ public class YoutubeSearchCondition {
private String format; // "SHORTS" or "LONG_FORM" private String format; // "SHORTS" or "LONG_FORM"
private java.util.Map<String, String> pageTokens; private java.util.Map<String, String> pageTokens;
private List<String> channelIds; // e.g. ["UC...", "UC..."] private List<String> channelIds; // e.g. ["UC...", "UC..."]
private String order; // search.list order 강제(: "viewCount"). 비우면 키워드 유무로 자동.
} }

View File

@ -54,24 +54,6 @@ youtube:
python: python:
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop} base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop}
# yt-dlp 원본 다운로드(로컬 ProcessBuilder 실행). 재가공 '원본 다운로드' 버튼이 사용.
ytdlp:
bin: ${YTDLP_BIN:yt-dlp} # PATH 에 있으면 그대로, 아니면 절대경로 지정
ffmpeg-location: ${FFMPEG_LOCATION:} # 비우면 PATH 사용. 필요시 ffmpeg bin 폴더 경로(예: D:\\utils\\ffmpeg\\bin)
max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
download:
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능)
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
translate:
base-url: ${TRANSLATE_BASE_URL:https://h-translate.tolag.shop}
# Gemini(무료 티어): 유튜브 URL → 화면 자막 추출 + 한국어 번역. AI Studio API 키 사용.
gemini:
api-key: ${GEMINI_API_KEY:}
model: ${GEMINI_MODEL:gemini-2.5-flash} # 유튜브 URL 영상 이해 지원
hlab: hlab:
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집 # 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
scheduler: scheduler:
@ -81,15 +63,5 @@ hlab:
channel-snapshot: channel-snapshot:
enabled: ${CHANNEL_SNAPSHOT_ENABLED:true} enabled: ${CHANNEL_SNAPSHOT_ENABLED:true}
cron: ${CHANNEL_SNAPSHOT_CRON:0 0 3 * * *} # 매일 03:00 (성장 추이 기록) cron: ${CHANNEL_SNAPSHOT_CRON:0 0 3 * * *} # 매일 03:00 (성장 추이 기록)
channel-discovery:
enabled: ${CHANNEL_DISCOVERY_ENABLED:true}
cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30 (추천 채널 발굴)
youtube: youtube:
daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정) daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정)
# 추천 채널 발굴: 지역 인기 Shorts 에서 작은구독자·고배율 떡상 채널 발굴
discovery:
regions: ${DISCOVERY_REGIONS:KR,JP,US}
max-subscribers: ${DISCOVERY_MAX_SUBS:100000} # 작은 채널 상한
min-ratio: ${DISCOVERY_MIN_RATIO:5.0} # 떡상 배율 하한
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
top-n: ${DISCOVERY_TOP_N:30}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View File

@ -19,7 +19,7 @@
전체 선택 전체 선택
</label> </label>
<button id="viewSelectedBtn" class="btn btn-outline" onclick="viewSelectedVideos()" style="display: none;"> <button id="viewSelectedBtn" class="btn btn-outline" onclick="viewSelectedVideos()" style="display: none;">
<i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> 선택 영상 보기 (<span id="selectedCount">0</span>) <i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> View Selected Videos (<span id="selectedCount">0</span>)
</button> </button>
<button class="btn btn-primary" onclick="openAddModal()"> <button class="btn btn-primary" onclick="openAddModal()">
<i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> 채널 추가 <i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> 채널 추가
@ -27,20 +27,10 @@
</div> </div>
</header> </header>
<!-- 국가별 탭 --> <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;">
<div class="flex gap-2 mb-4" id="countryTabs" style="flex-wrap: wrap;">
<button type="button" class="ctab btn btn-primary" data-country="ALL" onclick="filterByCountry('ALL', this)">전체</button>
<button type="button" class="ctab btn btn-ghost" data-country="KR" onclick="filterByCountry('KR', this)">한국</button>
<button type="button" class="ctab btn btn-ghost" data-country="JP" onclick="filterByCountry('JP', this)">일본</button>
<button type="button" class="ctab btn btn-ghost" data-country="US" onclick="filterByCountry('US', this)">미국</button>
<button type="button" class="ctab btn btn-ghost" data-country="ETC" onclick="filterByCountry('ETC', this)">기타</button>
</div>
<div id="channelGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;">
<!-- Channel Card Loop --> <!-- Channel Card Loop -->
<div class="card channel-card relative cursor-pointer hover:border-[var(--primary)] transition-all" <div class="card relative cursor-pointer hover:border-[var(--primary)] transition-all"
th:each="channel : ${channels}" th:each="channel : ${channels}"
th:data-country="${channel.country}"
th:onclick="'toggleCardSelection(this, event, ' + ${channel.id} + ')'" th:onclick="'toggleCardSelection(this, event, ' + ${channel.id} + ')'"
style="user-select: none;"> style="user-select: none;">
<!-- Custom Checkbox --> <!-- Custom Checkbox -->
@ -198,27 +188,20 @@
} }
} }
// 현재 탭에서 보이는(숨겨지지 않은) 카드의 체크박스만 반환
function visibleCheckboxes() {
return Array.from(document.querySelectorAll('.channel-checkbox'))
.filter(cb => { const c = cb.closest('.card'); return c && c.style.display !== 'none'; });
}
function updateSelectedCount() { function updateSelectedCount() {
const checkboxes = document.querySelectorAll('.channel-checkbox'); const checkboxes = document.querySelectorAll('.channel-checkbox');
const visibleBoxes = visibleCheckboxes(); const checked = document.querySelectorAll('.channel-checkbox:checked');
const checkedVisible = visibleBoxes.filter(cb => cb.checked); const count = checked.length;
const count = checkedVisible.length;
document.getElementById('selectedCount').innerText = count; document.getElementById('selectedCount').innerText = count;
document.getElementById('viewSelectedBtn').style.display = count > 0 ? 'flex' : 'none'; document.getElementById('viewSelectedBtn').style.display = count > 0 ? 'flex' : 'none';
// 전체 선택 상태는 '보이는 탭' 기준 // Update select all checkbox state
const selectAll = document.getElementById('selectAllCheckbox'); const selectAll = document.getElementById('selectAllCheckbox');
if (count === 0) { if (count === 0) {
selectAll.checked = false; selectAll.checked = false;
selectAll.indeterminate = false; selectAll.indeterminate = false;
} else if (count === visibleBoxes.length) { } else if (count === checkboxes.length) {
selectAll.checked = true; selectAll.checked = true;
selectAll.indeterminate = false; selectAll.indeterminate = false;
} else { } else {
@ -230,21 +213,20 @@
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
const card = cb.closest('.card'); const card = cb.closest('.card');
const customCb = card.querySelector('.custom-checkbox'); const customCb = card.querySelector('.custom-checkbox');
// lucide 가 <i data-lucide><svg> 로 치환하므로 둘 다 잡는다(없으면 무시). const checkIcon = customCb.querySelector('i');
const checkIcon = customCb.querySelector('svg, i');
if (cb.checked) { if (cb.checked) {
card.style.borderColor = 'var(--primary)'; card.style.borderColor = 'var(--primary)';
card.style.background = 'rgba(59, 130, 246, 0.05)'; card.style.background = 'rgba(59, 130, 246, 0.05)';
customCb.style.background = 'var(--primary)'; customCb.style.background = 'var(--primary)';
customCb.style.borderColor = 'var(--primary)'; customCb.style.borderColor = 'var(--primary)';
if (checkIcon) checkIcon.style.display = 'block'; checkIcon.style.display = 'block';
} else { } else {
card.style.borderColor = 'var(--glass-border)'; card.style.borderColor = 'var(--glass-border)';
card.style.background = 'var(--glass-bg)'; card.style.background = 'var(--glass-bg)';
customCb.style.background = 'var(--surface-2)'; customCb.style.background = 'var(--surface-2)';
customCb.style.borderColor = 'var(--border-strong)'; customCb.style.borderColor = 'var(--border-strong)';
if (checkIcon) checkIcon.style.display = 'none'; checkIcon.style.display = 'none';
} }
}); });
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@ -252,8 +234,8 @@
function toggleSelectAll() { function toggleSelectAll() {
const isChecked = document.getElementById('selectAllCheckbox').checked; const isChecked = document.getElementById('selectAllCheckbox').checked;
// 현재 활성 탭에서 보이는 카드만 선택/해제 const checkboxes = document.querySelectorAll('.channel-checkbox');
visibleCheckboxes().forEach(cb => cb.checked = isChecked); checkboxes.forEach(cb => cb.checked = isChecked);
updateSelectedCount(); updateSelectedCount();
} }
@ -275,46 +257,6 @@
} }
} }
// ---- 국가별 탭 필터 ----
function countryGroup(c) {
const v = (c || '').toUpperCase();
if (v === 'KR' || v === 'JP' || v === 'US') return v;
return 'ETC';
}
function filterByCountry(code, btn) {
document.querySelectorAll('#countryTabs .ctab').forEach(b => {
b.classList.toggle('btn-primary', b === btn);
b.classList.toggle('btn-ghost', b !== btn);
});
let shown = 0;
document.querySelectorAll('#channelGrid .channel-card').forEach(card => {
const g = countryGroup(card.getAttribute('data-country'));
const visible = (code === 'ALL') || (g === code);
card.style.display = visible ? '' : 'none';
if (visible) shown++;
else { // 숨겨진 카드는 선택 해제(전체 선택/선택 영상 보기를 현재 탭으로 한정)
const cb = card.querySelector('.channel-checkbox');
if (cb) cb.checked = false;
}
});
const empty = document.getElementById('emptyCountry');
if (empty) empty.style.display = shown === 0 ? 'block' : 'none';
updateSelectedCount();
}
function updateCountryTabCounts() {
const counts = { ALL: 0, KR: 0, JP: 0, US: 0, ETC: 0 };
document.querySelectorAll('#channelGrid .channel-card').forEach(card => {
counts.ALL++;
counts[countryGroup(card.getAttribute('data-country'))]++;
});
document.querySelectorAll('#countryTabs .ctab').forEach(b => {
const code = b.getAttribute('data-country');
const base = b.textContent.replace(/\s*\(\d+\)\s*$/, '');
b.textContent = base + ' (' + (counts[code] || 0) + ')';
});
}
document.addEventListener('DOMContentLoaded', updateCountryTabCounts);
// Close modal on outside click // Close modal on outside click
window.onclick = function (event) { window.onclick = function (event) {
const modal = document.getElementById('addChannelModal'); const modal = document.getElementById('addChannelModal');

View File

@ -27,9 +27,6 @@
<a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'"> <a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'">
<i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span> <i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span>
</a> </a>
<a th:href="@{/recommend}" class="nav-item" th:classappend="${currentPage == 'recommend'} ? 'active'">
<i data-lucide="sparkles" class="nav-icon"></i><span class="nav-text">추천 채널</span>
</a>
<a th:href="@{/channels}" class="nav-item" th:classappend="${currentPage == 'channels'} ? 'active'"> <a th:href="@{/channels}" class="nav-item" th:classappend="${currentPage == 'channels'} ? 'active'">
<i data-lucide="users" class="nav-icon"></i><span class="nav-text">채널</span> <i data-lucide="users" class="nav-icon"></i><span class="nav-text">채널</span>
</a> </a>

View File

@ -3,84 +3,86 @@
layout:decorate="~{layout/base}"> layout:decorate="~{layout/base}">
<head> <head>
<title>h-lab - 채널 영상 모아보기</title> <title>h-lab - Multi-Channel Videos</title>
</head> </head>
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <header class="mb-6">
<div> <div class="flex items-center gap-2 mb-4">
<div class="flex items-center gap-2 mb-1"> <a th:href="@{/channels}" class="text-muted hover:text-white transition-colors">
<a th:href="@{/channels}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a> <i data-lucide="arrow-left" style="width: 20px;"></i>
<h1>채널 영상 모아보기</h1> </a>
</div> <h1 class="text-2xl font-bold">Search within Selected Channels</h1>
<p class="sub">선택한 <span th:text="${channels != null ? #lists.size(channels) : 0}">0</span>개 채널의 영상을 한 번에 검색·정렬하고 수집함에 담습니다.</p>
</div>
</div> </div>
<!-- 채널 칩 --> <div class="card p-6 mb-6">
<div class="card mb-4" th:if="${channels != null}"> <div class="text-sm font-bold text-muted mb-4">Tracking <span th:text="${channels != null ? #lists.size(channels) : 0}">0</span> Channels</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-4" th:if="${channels != null}">
<div th:each="channel : ${channels}" class="flex items-center gap-2" style="padding:5px 10px; border-radius:999px; background:var(--surface-2); border:1px solid var(--glass-border);"> <div th:each="channel : ${channels}" class="flex items-center gap-2 p-2 rounded-lg bg-[var(--surface-2)] border border-[var(--glass-border)]">
<img th:src="${channel.thumbnailUrl}" alt="" th:if="${channel.thumbnailUrl != null}" style="width:24px; height:24px; border-radius:50%; object-fit:cover;"> <img th:src="${channel.thumbnailUrl}" alt="Thumbnail"
<span class="text-sm" th:text="${channel.title}">채널</span> style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;" th:if="${channel.thumbnailUrl != null}">
<span class="text-sm font-medium" th:text="${channel.title}">Channel Name</span>
</div> </div>
</div> </div>
</div> </div>
</header>
<!-- 검색 조건 --> <!-- Search / Filter Area (Copied from videos.html) -->
<div class="card mb-4"> <div class="card mb-6">
<form id="searchForm" onsubmit="event.preventDefault(); executeSearch();"> <form id="searchForm" onsubmit="event.preventDefault(); executeSearch();">
<div style="display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end;"> <div style="display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end;">
<div style="display:flex; flex-direction:column; gap:6px; min-width:150px;"> <!-- Period -->
<label class="text-sm text-muted">기간</label> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
<select id="periodDays" class="pub-in" style="height:42px;"> <label class="text-sm font-bold text-muted">Period</label>
<option value="1">최근 1일</option> <select id="periodDays" class="w-full"
<option value="7" selected>최근 7일</option> style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
<option value="10">최근 10일</option> <option value="1" style="background: var(--surface); color: var(--text);">Within 1 Day</option>
<option value="15">최근 15일</option> <option value="7" style="background: var(--surface); color: var(--text);" selected>Within 7 Days</option>
<option value="30">최근 30일</option> <option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option>
<option value="0">전체</option> <option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</option>
<option value="30" style="background: var(--surface); color: var(--text);">Within 30 Days</option>
<option value="0" style="background: var(--surface); color: var(--text);">전부 (All)</option>
</select> </select>
</div> </div>
<div style="display:flex; flex-direction:column; gap:6px; min-width:160px;">
<label class="text-sm text-muted">형식</label> <!-- Format -->
<div class="flex gap-4" style="padding:0 10px; height:42px; align-items:center; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md);"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
<label class="flex items-center gap-1 cursor-pointer"><input type="radio" name="format" value="SHORTS" checked> Shorts</label> <label class="text-sm font-bold text-muted">Format</label>
<label class="flex items-center gap-1 cursor-pointer"><input type="radio" name="format" value="LONG_FORM"> 롱폼</label> <div class="flex gap-4 w-full" style="padding: 8px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="radio" name="format" value="SHORTS" checked> Shorts
</label>
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="radio" name="format" value="LONG_FORM"> Long
</label>
</div> </div>
</div> </div>
<div style="display:flex; flex-direction:column; gap:6px; min-width:130px;">
<label class="text-sm text-muted">불러올 수</label> <!-- Page Size -->
<select id="pageSize" class="pub-in" style="height:42px;"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;">
<option value="20">20개</option> <label class="text-sm font-bold text-muted">Load Size</label>
<option value="50" selected>50개</option> <select id="pageSize" class="w-full"
<option value="100">100개</option> style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
<option value="20" style="background: var(--surface); color: var(--text);">20 items</option>
<option value="50" style="background: var(--surface); color: var(--text);" selected>50 items</option>
<option value="100" style="background: var(--surface); color: var(--text);">100 items</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary px-6 py-2 flex items-center gap-1" id="searchBtn" style="height:42px;"> </div>
<i data-lucide="search" style="width:16px;"></i> 검색
<div class="mt-4 flex justify-end">
<button type="submit" class="btn btn-primary px-8 py-2 flex items-center justify-center" id="searchBtn">
<i data-lucide="search" style="width: 18px; margin-right: 6px;"></i> <span class="font-bold">Search within Channels</span>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<!-- 정렬 + 담기 툴바 --> <!-- 수집 툴바 -->
<div class="flex items-center justify-between mb-3" style="gap:12px; flex-wrap:wrap;"> <div class="flex items-center justify-between mb-3" style="gap:12px;">
<div class="flex items-center gap-2"> <span id="selInfo" class="text-sm text-muted"></span>
<span class="text-sm text-muted">정렬</span>
<select id="sortSel" class="pub-in" style="width:auto; padding:7px 9px;" onchange="onSortChange()">
<option value="performance">배율순(떡상)</option>
<option value="views">조회수순</option>
<option value="date">최신순</option>
<option value="subs">구독자순</option>
</select>
<span id="selInfo" class="text-sm text-muted ml-2"></span>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<label class="flex items-center gap-1 text-sm text-muted cursor-pointer" style="user-select:none;">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)"> 전체 선택
</label>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="collectSelected()" id="collectSelBtn"> <button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="collectSelected()" id="collectSelBtn">
<i data-lucide="check-square" style="width:16px;"></i> 선택 담기 <i data-lucide="check-square" style="width:16px;"></i> 선택 담기
</button> </button>
@ -90,216 +92,358 @@
</div> </div>
</div> </div>
<!-- 결과 카드 그리드 --> <!-- Video List Table -->
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:1rem;"> <div class="card p-0" style="overflow-x: auto;">
<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">검색 조건을 정하고 ‘검색’을 누르세요.</div> <table class="w-full rcards" style="border-collapse: collapse; text-align: left;">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
<tr>
<th class="p-4 text-sm font-bold text-muted"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)" title="전체 선택"></th>
<th class="p-4 text-sm font-bold text-muted">Thumbnail</th>
<th class="p-4 text-sm font-bold text-muted">Title</th>
<th class="p-4 text-sm font-bold text-muted">Channel</th>
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('date')" style="user-select: none;">
Publish Date <span id="sort-icon-date" style="display: inline-block; width: 14px;"></span>
</th>
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('performance')" style="user-select: none;">
Performance <span id="sort-icon-performance" style="display: inline-block; width: 14px;"></span>
</th>
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('views')" style="user-select: none;">
Views <span id="sort-icon-views" style="display: inline-block; width: 14px;"></span>
</th>
<th class="p-4 text-sm font-bold text-muted cursor-pointer hover:text-white" onclick="handleSort('subs')" style="user-select: none;">
Subscribers <span id="sort-icon-subs" style="display: inline-block; width: 14px;"></span>
</th>
<th class="p-4 text-sm font-bold text-muted">Download</th>
</tr>
</thead>
<tbody id="resultBody">
<tr>
<td colspan="9" class="p-8 text-center text-muted">Select search conditions and click Search to find videos from these channels.</td>
</tr>
</tbody>
</table>
</div> </div>
<div class="mt-4 flex justify-center"> <div class="mt-4 flex justify-center">
<button id="loadMoreBtn" class="btn btn-secondary px-6 py-3" style="display:none;" onclick="handleLoadMore()">더 보기</button> <button id="loadMoreBtn" class="btn btn-secondary w-full p-3" style="display: none;" onclick="handleLoadMore()">Load More</button>
</div> </div>
<!-- 미리보기 모달 --> <!-- Video Player Modal -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()"> <div id="videoModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.85); align-items: center; justify-content: center;" onclick="if(event.target === this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);"> <div style="position: relative; width: 95%; max-width: 1200px; background-color: var(--glass-bg, var(--surface)); border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">영상</h3> <h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video Player</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button> <button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size: 28px; line-height: 1; background: none; border: none; cursor: pointer;">&times;</button>
</div> </div>
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div> <div id="iframeContainer" style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; background: #000;"></div>
</div> </div>
</div> </div>
<style>
.pub-in { width:100%; padding:9px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem; }
.pub-in option { background:var(--surface); color:var(--text); }
.vc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; transition:border-color .15s; }
.vc-card.sel { border-color:var(--primary); }
.vc-thumb-wrap { position:relative; cursor:pointer; }
.vc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; display:block; }
.vc-play { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.55); border-radius:50%; width:38px; height:38px; display:flex; align-items:center; justify-content:center; }
.vc-chk { position:absolute; top:8px; left:8px; width:20px; height:20px; cursor:pointer; z-index:2; }
.vc-body { padding:11px; }
.vc-title { font-weight:700; font-size:0.9rem; line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.vc-ratio { display:inline-block; color:#fff; font-weight:800; font-size:0.78rem; padding:2px 7px; border-radius:999px; }
.vc-actions { display:flex; gap:6px; margin-top:9px; }
</style>
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ /*<![CDATA[*/
const selectedYoutubeChannelIds = /*[[${selectedYoutubeIds}]]*/ []; const selectedYoutubeChannelIds = /*[[${selectedYoutubeIds}]]*/ [];
/*]]>*/
let allLoadedVideos = []; let allLoadedVideos = [];
let currentTokens = {}; let currentTokens = {};
let displayedCount = 0; let displayedCount = 0;
let currentCondition = {}; let currentCondition = {};
let currentSort = 'performance';
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } let currentSortColumn = 'performance';
function fmt(n){ return (n==null)?'0':Number(n).toLocaleString(); } let currentSortOrder = 'desc';
function handleSort(column) {
if (currentSortColumn === column) {
currentSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
} else {
currentSortColumn = column;
currentSortOrder = 'desc';
}
updateSortIcons();
function onSortChange(){
currentSort = document.getElementById('sortSel').value;
const size = parseInt(document.getElementById('pageSize').value) || 50; const size = parseInt(document.getElementById('pageSize').value) || 50;
renderGrid(displayedCount > 0 ? displayedCount : size); const targetLimit = displayedCount > 0 ? displayedCount : size;
renderTable(targetLimit);
} }
function executeSearch(){ function updateSortIcons() {
['date', 'performance', 'views', 'subs'].forEach(col => {
const iconSpan = document.getElementById('sort-icon-' + col);
if (iconSpan) {
if (currentSortColumn === col) {
iconSpan.innerText = currentSortOrder === 'asc' ? '▲' : '▼';
} else {
iconSpan.innerText = '';
}
}
});
}
function executeSearch() {
const keyword = ''; // Removed input field, default to empty
const periodDays = parseInt(document.getElementById('periodDays').value); const periodDays = parseInt(document.getElementById('periodDays').value);
const formatRadios = document.querySelectorAll('input[name="format"]:checked'); const formatRadios = document.querySelectorAll('input[name="format"]:checked');
const format = formatRadios.length > 0 ? formatRadios[0].value : 'SHORTS'; const format = formatRadios.length > 0 ? formatRadios[0].value : 'SHORTS';
currentCondition = { keyword:'', channelIds:selectedYoutubeChannelIds, periodDays:periodDays, format:format, pageTokens:{} };
allLoadedVideos = []; currentTokens = {}; displayedCount = 0; currentCondition = {
keyword: keyword,
channelIds: selectedYoutubeChannelIds, // Use selected channels
periodDays: periodDays,
format: format,
pageTokens: {}
};
allLoadedVideos = [];
currentTokens = {};
displayedCount = 0;
document.getElementById('resultBody').innerHTML = '';
document.getElementById('loadMoreBtn').style.display = 'none'; document.getElementById('loadMoreBtn').style.display = 'none';
fetchMore(true); fetchMore(true);
} }
async function fetchMore(isFirst){ async function fetchMore(isFirst) {
const searchBtn = document.getElementById('searchBtn'); const searchBtn = document.getElementById('searchBtn');
const loadMoreBtn = document.getElementById('loadMoreBtn'); const loadMoreBtn = document.getElementById('loadMoreBtn');
const grid = document.getElementById('grid'); const resultBody = document.getElementById('resultBody');
if (isFirst){
searchBtn.disabled = true; searchBtn.innerHTML = '<span class="text-sm">검색 중…</span>'; if (isFirst) {
grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">'+selectedYoutubeChannelIds.length+'개 채널에서 불러오는 중… 잠시 걸립니다.</div>'; searchBtn.disabled = true;
} else { loadMoreBtn.disabled = true; loadMoreBtn.innerHTML = '불러오는 중…'; } searchBtn.innerHTML = '<span class="text-sm">Searching...</span>';
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-muted">Loading results from ' + selectedYoutubeChannelIds.length + ' channels... This may take a moment.</td></tr>';
} else {
loadMoreBtn.disabled = true;
loadMoreBtn.innerHTML = 'Loading...';
}
currentCondition.pageTokens = currentTokens; currentCondition.pageTokens = currentTokens;
try { try {
const response = await fetch('/api/youtube/search', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(currentCondition) }); const response = await fetch('/api/youtube/search', {
if (!response.ok) throw new Error('검색 API 실패'); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentCondition)
});
if (!response.ok) throw new Error('API request failed');
const pageDto = await response.json(); const pageDto = await response.json();
if (isFirst) {
resultBody.innerHTML = '';
}
allLoadedVideos = allLoadedVideos.concat(pageDto.items || []); allLoadedVideos = allLoadedVideos.concat(pageDto.items || []);
currentTokens = pageDto.nextTokens || {}; currentTokens = pageDto.nextTokens || {};
const size = parseInt(document.getElementById('pageSize').value) || 50; const size = parseInt(document.getElementById('pageSize').value) || 50;
renderGrid(isFirst ? size : displayedCount + size); const targetLimit = isFirst ? size : displayedCount + size;
} catch(error){ renderTable(targetLimit);
} catch (error) {
console.error('Search error:', error); console.error('Search error:', error);
if (isFirst) grid.innerHTML = '<div style="grid-column:1/-1; padding:2rem; text-align:center; color:#f87171;">검색 중 오류가 발생했습니다.</div>'; if (isFirst) {
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-danger">Error occurred during search.</td></tr>';
}
} finally { } finally {
if (isFirst){ searchBtn.disabled = false; searchBtn.innerHTML = '<i data-lucide="search" style="width:16px;"></i> 검색'; if(window.lucide) lucide.createIcons(); } if (isFirst) {
searchBtn.disabled = false;
searchBtn.innerHTML = '<i data-lucide="search" style="width: 18px; margin-right: 6px;"></i> Search within Channels';
if (window.lucide) window.lucide.createIcons();
}
} }
} }
function handleLoadMore(){ function handleLoadMore() {
const size = parseInt(document.getElementById('pageSize').value) || 50; const size = parseInt(document.getElementById('pageSize').value) || 50;
if (displayedCount < allLoadedVideos.length) renderGrid(displayedCount + size); if (displayedCount < allLoadedVideos.length) {
else fetchMore(false); renderTable(displayedCount + size);
} else {
fetchMore(false);
}
} }
function ratioOf(v){ function renderTable(limit) {
if (v.viewCount != null && v.subscriberCount != null && v.subscriberCount > 0) return v.viewCount / v.subscriberCount; const resultBody = document.getElementById('resultBody');
return (v.viewCount > 0) ? Infinity : -1;
}
function renderGrid(limit){ if (allLoadedVideos.length === 0) {
const grid = document.getElementById('grid'); resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-muted">No results found within selected channels.</td></tr>';
if (allLoadedVideos.length === 0){
grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">선택한 채널에서 결과를 찾지 못했습니다.</div>';
document.getElementById('loadMoreBtn').style.display = 'none'; document.getElementById('loadMoreBtn').style.display = 'none';
return; return;
} }
allLoadedVideos.sort((a,b)=>{
let va=0, vb=0; allLoadedVideos.sort((a, b) => {
if (currentSort==='views'){ va=a.viewCount||0; vb=b.viewCount||0; } let valA = 0, valB = 0;
else if (currentSort==='subs'){ va=a.subscriberCount||0; vb=b.subscriberCount||0; } if (currentSortColumn === 'views') {
else if (currentSort==='date'){ va=new Date(a.publishedAt).getTime(); vb=new Date(b.publishedAt).getTime(); } valA = a.viewCount || 0;
else { va=ratioOf(a); vb=ratioOf(b); } valB = b.viewCount || 0;
return vb - va; // 내림차순 } else if (currentSortColumn === 'subs') {
valA = a.subscriberCount || 0;
valB = b.subscriberCount || 0;
} else if (currentSortColumn === 'date') {
valA = new Date(a.publishedAt).getTime();
valB = new Date(b.publishedAt).getTime();
} else if (currentSortColumn === 'performance') {
valA = (a.subscriberCount > 0) ? (a.viewCount / a.subscriberCount) : ((a.viewCount > 0) ? Infinity : -1);
valB = (b.subscriberCount > 0) ? (b.viewCount / b.subscriberCount) : ((b.viewCount > 0) ? Infinity : -1);
}
if (valA < valB) return currentSortOrder === 'asc' ? -1 : 1;
if (valA > valB) return currentSortOrder === 'asc' ? 1 : -1;
return 0;
}); });
resultBody.innerHTML = '';
const actualLimit = Math.min(limit, allLoadedVideos.length); const actualLimit = Math.min(limit, allLoadedVideos.length);
const list = allLoadedVideos.slice(0, actualLimit); const videosToRender = allLoadedVideos.slice(0, actualLimit);
grid.innerHTML = list.map(v=>{
const d = new Date(v.publishedAt); videosToRender.forEach(video => {
const dateStr = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); const tr = document.createElement('tr');
const r = ratioOf(v); tr.style.borderBottom = '1px solid var(--glass-border)';
let ratioStr='-', ratioBg='#9ca3af';
if (r >= 0 && r !== Infinity){ ratioStr=r.toFixed(1)+'x'; ratioBg = r>=10 ? '#ef4444' : (r>=2 ? '#f59e0b' : '#10b981'); } const pubDate = new Date(video.publishedAt);
else if (r === Infinity){ ratioStr='∞'; ratioBg='#ef4444'; } let formattedDate = pubDate.getFullYear() + '-' + String(pubDate.getMonth() + 1).padStart(2, '0') + '-' + String(pubDate.getDate()).padStart(2, '0') + ' ' + String(pubDate.getHours()).padStart(2, '0') + ':' + String(pubDate.getMinutes()).padStart(2, '0');
const safeTitle = esc(v.title).replace(/"/g,'&quot;');
return '<div class="vc-card" id="vc-'+v.videoId+'">' const viewStr = video.viewCount != null ? video.viewCount.toLocaleString() : '0';
+ '<div class="vc-thumb-wrap">' const subStr = video.subscriberCount != null ? video.subscriberCount.toLocaleString() : '0';
+ '<input type="checkbox" class="vc-chk rowChk" value="'+v.videoId+'" onclick="event.stopPropagation()" onchange="onRowCheck(this,\''+v.videoId+'\')">'
+ '<img class="vc-thumb" src="'+esc(v.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(v.videoId)+'" data-title="'+safeTitle+'" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">' let perfStr = '-';
+ '<div class="vc-play" data-vid="'+esc(v.videoId)+'" data-title="'+safeTitle+'" onclick="openVideoModal(this.dataset.vid, this.dataset.title)"><i data-lucide="play" style="width:16px; color:#fff;"></i></div>' let perfStyle = 'color: var(--text-muted);';
+ '</div>' if (video.viewCount != null && video.subscriberCount != null && video.subscriberCount > 0) {
+ '<div class="vc-body">' const ratio = (video.viewCount / video.subscriberCount) * 100;
+ '<div class="vc-title"><a href="https://www.youtube.com/watch?v='+esc(v.videoId)+'" target="_blank" class="hover:underline" style="color:var(--text);">'+esc(v.title)+'</a></div>' perfStr = ratio.toFixed(1) + '%';
+ '<div class="text-sm text-muted mt-1" style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"><a href="https://www.youtube.com/channel/'+esc(v.channelId)+'" target="_blank" class="hover:underline">'+esc(v.channelTitle)+'</a></div>' if (ratio >= 100) perfStyle = 'color: #ef4444; font-weight: bold;';
+ '<div class="mt-2 flex items-center gap-2" style="flex-wrap:wrap;">' else if (ratio >= 30) perfStyle = 'color: #f59e0b; font-weight: bold;';
+ '<span class="vc-ratio" style="background:'+ratioBg+';">🔥 '+ratioStr+'</span>' else perfStyle = 'color: #10b981; font-weight: bold;';
+ '<span class="text-sm text-muted">👁 '+fmt(v.viewCount)+'</span>' }
+ '<span class="text-sm text-muted">👤 '+fmt(v.subscriberCount)+'</span>'
+ '</div>' const safeTitle = video.title.replace(/"/g, "&quot;");
+ '<div class="text-xs text-muted mt-1">'+dateStr+'</div>'
+ '<div class="vc-actions">' tr.innerHTML =
+ '<button class="btn btn-secondary px-3 py-2" style="font-size:0.8rem;" onclick="downloadVideo(\'https://www.youtube.com/watch?v='+v.videoId+'\')" title="고화질 다운로드"><i data-lucide="download" style="width:14px;"></i> 다운로드</button>' '<td class="p-4">' +
+ '<button class="btn btn-secondary px-3 py-2" style="font-size:0.8rem;" onclick="copyToClipboard(\'https://www.youtube.com/watch?v='+v.videoId+'\')" title="URL 복사"><i data-lucide="copy" style="width:14px;"></i></button>' '<input type="checkbox" class="rowChk" value="' + video.videoId + '" onchange="updateSelInfo()">' +
+ '</div>' '</td>' +
+ '</div></div>'; '<td class="p-4">' +
}).join(''); '<div style="position: relative; display: inline-block; cursor: pointer;" data-videoid="' + video.videoId + '" data-videotitle="' + safeTitle + '" onclick="openVideoModal(this.dataset.videoid, this.dataset.videotitle)">' +
'<img src="' + video.thumbnailUrl + '" alt="Thumb" style="width: 120px; height: 68px; object-fit: cover; border-radius: 6px; transition: transform 0.2s;" onmouseover="this.style.transform=\'scale(1.05)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
'<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.6); border-radius: 50%; padding: 8px; display: flex; align-items: center; justify-content: center;">' +
'<i data-lucide="play" style="color: var(--text); width: 16px; height: 16px; fill: white;"></i>' +
'</div>' +
'</div>' +
'</td>' +
'<td class="p-4">' +
'<div class="flex items-start justify-between gap-2" style="max-width: 300px;">' +
'<div class="font-bold text-sm" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1;">' +
'<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline">' + video.title + '</a>' +
'</div>' +
'<button onclick="copyToClipboard(\'https://www.youtube.com/watch?v=' + video.videoId + '\')" class="text-muted hover:text-white flex-shrink-0" title="URL 복사" style="background: none; border: none; cursor: pointer; padding: 2px;">' +
'<i data-lucide="copy" style="width: 14px; height: 14px;"></i>' +
'</button>' +
'</div>' +
'</td>' +
'<td class="p-4 text-sm text-muted">' +
'<div class="flex items-center gap-2">' +
'<a href="https://www.youtube.com/channel/' + video.channelId + '" target="_blank" class="hover:underline font-semibold" style="color: #e2e8f0;">' + video.channelTitle + '</a>' +
'</div>' +
'</td>' +
'<td class="p-4 text-sm text-muted">' + formattedDate + '</td>' +
'<td class="p-4 text-sm" style="' + perfStyle + '">' + perfStr + '</td>' +
'<td class="p-4 text-sm">' + viewStr + '</td>' +
'<td class="p-4 text-sm">' + subStr + '</td>' +
'<td class="p-4 text-sm">' +
'<button onclick="downloadVideo(\'https://www.youtube.com/watch?v=' + video.videoId + '\')" class="btn btn-secondary p-2 flex items-center justify-center" title="고화질 다운로드">' +
'<i data-lucide="download" style="width: 16px; height: 16px;"></i>' +
'</button>' +
'</td>';
resultBody.appendChild(tr);
});
displayedCount = actualLimit; displayedCount = actualLimit;
updateLoadMoreButton(); updateLoadMoreButton();
const selectAll = document.getElementById('selectAll'); if (selectAll) selectAll.checked = false; const selectAll = document.getElementById('selectAll');
if (selectAll) selectAll.checked = false;
updateSelInfo(); updateSelInfo();
if (window.lucide) lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
} }
function onRowCheck(cb, vid){ function downloadVideo(videoUrl) {
const card = document.getElementById('vc-'+vid); // cobalt.tools is a clean, ad-free high-quality downloader
if (card) card.classList.toggle('sel', cb.checked); // Since it doesn't support direct URL params for downloading in the same way,
updateSelInfo(); // we'll open a popular service like 'ssyoutube' or similar which is easier for direct linking.
const downloadUrl = videoUrl.replace('youtube.com', 'ssyoutube.com');
window.open(downloadUrl, '_blank');
} }
function updateLoadMoreButton(){ function updateLoadMoreButton() {
const b = document.getElementById('loadMoreBtn'); const loadMoreBtn = document.getElementById('loadMoreBtn');
if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0){ b.style.display='block'; b.disabled=false; b.innerHTML='더 보기'; } if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0) {
else b.style.display='none'; loadMoreBtn.style.display = 'block';
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load More (Next 50)';
} else {
loadMoreBtn.style.display = 'none';
}
} }
function downloadVideo(videoUrl){ window.open(videoUrl.replace('youtube.com','ssyoutube.com'), '_blank'); } function openVideoModal(videoId, title) {
function openVideoModal(videoId, title){ const modal = document.getElementById('videoModal');
document.getElementById('modalTitle').textContent = title || ''; const container = document.getElementById('iframeContainer');
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+videoId+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'; const modalTitle = document.getElementById('modalTitle');
document.getElementById('videoModal').style.display = 'flex'; modalTitle.textContent = title;
container.innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/' + videoId + '?autoplay=1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
modal.style.display = 'flex';
}
function closeVideoModal() {
document.getElementById('iframeContainer').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
alert('URL이 클립보드에 복사되었습니다.');
});
} }
function closeVideoModal(){ document.getElementById('iframeContainer').innerHTML=''; document.getElementById('videoModal').style.display='none'; }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
function copyToClipboard(text){ navigator.clipboard.writeText(text).then(()=>{ alert('URL이 클립보드에 복사되었습니다.'); }); }
// ---- 수집함에 담기 ---- // ---- 수집함에 담기 ----
function toggleSelectAll(checked){ function toggleSelectAll(checked) {
document.querySelectorAll('.rowChk').forEach(cb=>{ cb.checked=checked; const card=cb.closest('.vc-card'); if(card) card.classList.toggle('sel',checked); }); document.querySelectorAll('.rowChk').forEach(cb => cb.checked = checked);
updateSelInfo(); updateSelInfo();
} }
function updateSelInfo(){ function updateSelInfo() {
const n = document.querySelectorAll('.rowChk:checked').length; const checked = document.querySelectorAll('.rowChk:checked').length;
const info = document.getElementById('selInfo'); if (info) info.textContent = n>0 ? (n+'개 선택됨') : ''; const info = document.getElementById('selInfo');
if (info) info.textContent = checked > 0 ? (checked + '개 선택됨') : '';
} }
async function postCollect(items, btn){ async function postCollect(items, btn) {
if (!items || items.length === 0){ alert('담을 영상이 없습니다.'); return; } if (!items || items.length === 0) { alert('담을 영상이 없습니다.'); return; }
const original = btn ? btn.innerHTML : null; const original = btn ? btn.innerHTML : null;
if (btn){ btn.disabled=true; btn.innerHTML='담는 중...'; } if (btn) { btn.disabled = true; btn.innerHTML = '담는 중...'; }
try { try {
const res = await fetch('/api/youtube/collect', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(items) }); const res = await fetch('/api/youtube/collect', {
const json = await res.json().catch(()=>({})); method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items)
if (!res.ok || (json && json.success === false)) throw new Error((json && json.message) || ('HTTP '+res.status)); });
const json = await res.json().catch(() => ({}));
if (!res.ok || (json && json.success === false)) throw new Error((json && json.message) || ('HTTP ' + res.status));
const d = json.data || {}; const d = json.data || {};
alert('수집함에 담기 완료\n저장 '+(d.saved||0)+'건, 중복/스킵 '+(d.skipped||0)+'건'); alert('수집함에 담기 완료\\n저장 ' + (d.saved || 0) + '건, 중복/스킵 ' + (d.skipped || 0) + '건');
} catch(e){ alert('담기 실패: '+e.message); } } catch (e) {
finally { if (btn){ btn.disabled=false; btn.innerHTML=original; if(window.lucide) lucide.createIcons(); } } alert('담기 실패: ' + e.message);
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = original; if (window.lucide) window.lucide.createIcons(); }
} }
function collectSelected(){ }
const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb=>cb.value); function collectSelected() {
if (ids.length === 0){ alert('선택된 영상이 없습니다. 체크박스로 선택하세요.'); return; } const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb => cb.value);
if (ids.length === 0) { alert('선택된 영상이 없습니다. 체크박스로 선택하세요.'); return; }
const set = new Set(ids); const set = new Set(ids);
postCollect(allLoadedVideos.filter(v=>set.has(v.videoId)), document.getElementById('collectSelBtn')); postCollect(allLoadedVideos.filter(v => set.has(v.videoId)), document.getElementById('collectSelBtn'));
} }
function collectAll(){ function collectAll() {
if (allLoadedVideos.length === 0){ alert('먼저 검색하세요.'); return; } if (allLoadedVideos.length === 0) { alert('먼저 검색하세요.'); return; }
if (!confirm('현재 로드된 '+allLoadedVideos.length+'개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return; if (!confirm('현재 로드된 ' + allLoadedVideos.length + '개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return;
postCollect(allLoadedVideos, document.getElementById('collectAllBtn')); postCollect(allLoadedVideos, document.getElementById('collectAllBtn'));
} }
/*]]>*/
</script> </script>
</div> </div>
</body> </body>

View File

@ -1,132 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head><title>h-lab - 추천 채널</title></head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>추천 채널</h1>
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
</div>
<div class="actions">
<button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button>
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
</div>
</div>
<div id="status" class="text-sm mb-3" style="display:none;"></div>
<!-- 지역 탭 -->
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;">
<button class="btn btn-primary px-3 py-2 rc-tab" data-region="" onclick="setTab(this)">전체</button>
<button class="btn btn-secondary px-3 py-2 rc-tab" data-region="KR" onclick="setTab(this)">🇰🇷 KR</button>
<button class="btn btn-secondary px-3 py-2 rc-tab" data-region="JP" onclick="setTab(this)">🇯🇵 JP</button>
<button class="btn btn-secondary px-3 py-2 rc-tab" data-region="US" onclick="setTab(this)">🇺🇸 US</button>
</div>
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div>
<!-- 영상 미리보기 모달 (다른 페이지와 동일 패턴) -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
</div>
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
</div>
</div>
<style>
.rc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; }
.rc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; display:block; }
.rc-body { padding:12px; }
.rc-ratio { display:inline-block; background:#ef4444; color:#fff; font-weight:800; font-size:0.8rem; padding:2px 8px; border-radius:999px; }
.rc-actions { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
.rc-actions .btn { text-decoration:none; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/recommended-channels';
async function api(url, opts){ const r=await fetch(url,opts); const j=await r.json().catch(()=>({})); if(!r.ok||(j&&j.success===false)) throw new Error((j&&j.message)||('HTTP '+r.status)); return j.data; }
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
let ALL_RECS = [];
let CUR_REGION = ''; // ''=전체
let VIEW_STATUS = 'NEW'; // 'NEW'(추천) | 'EXCLUDED'(제외 목록)
async function load(){
try { ALL_RECS = await api(API + '?status=' + VIEW_STATUS); renderGrid(); }
catch(e){ document.getElementById('grid').innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
}
function toggleView(){
VIEW_STATUS = (VIEW_STATUS === 'NEW') ? 'EXCLUDED' : 'NEW';
const btn = document.getElementById('viewToggle');
btn.innerHTML = (VIEW_STATUS === 'EXCLUDED')
? '<i data-lucide="list" style="width:15px;"></i> 추천 목록'
: '<i data-lucide="archive" style="width:15px;"></i> 제외 목록';
load();
}
function setTab(btn){
CUR_REGION = btn.dataset.region || '';
document.querySelectorAll('.rc-tab').forEach(b=>{ const on=(b===btn); b.classList.toggle('btn-primary',on); b.classList.toggle('btn-secondary',!on); });
renderGrid();
}
function renderGrid(){
const grid=document.getElementById('grid');
const excludedView = (VIEW_STATUS === 'EXCLUDED');
const list = CUR_REGION ? ALL_RECS.filter(c=>c.region===CUR_REGION) : ALL_RECS;
if(!list.length){
grid.innerHTML='<div class="text-muted">'
+ (excludedView ? '제외한 채널이 없습니다.'
: (CUR_REGION ? (CUR_REGION+' 지역 추천 채널이 없습니다.') : '아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.'))
+ '</div>';
return;
}
grid.innerHTML=list.map(c=>{
const moveBtn = '<a class="btn btn-secondary px-3 py-2" href="https://www.youtube.com/channel/'+esc(c.channelId)+'" target="_blank" title="유튜브 채널로 이동"><i data-lucide="external-link" style="width:14px;"></i> 채널 이동</a>';
const actions = excludedView
? '<button class="btn btn-primary px-3 py-2" onclick="restore('+c.id+')"><i data-lucide="rotate-ccw" style="width:14px;"></i> 복원</button>' + moveBtn
: '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' + moveBtn
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>';
return '<div class="rc-card" id="rc-'+c.id+'">'
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">'
+ '<div class="rc-body">'
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
+ '<div class="rc-actions">'+actions+'</div>'
+ '</div></div>';
}).join('');
if(window.lucide) lucide.createIcons();
}
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } }
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } }
async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } }
function openVideoModal(el){
const vid = el.dataset.vid || '';
document.getElementById('modalTitle').textContent = el.dataset.title || '';
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+vid+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').style.display = 'flex';
}
function closeVideoModal(){
document.getElementById('iframeContainer').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
async function runDiscovery(){
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)';
try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(',')); await load(); }
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
}
load();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -26,50 +26,9 @@
<div class="modal-card"> <div class="modal-card">
<div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div> <div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body"> <div class="modal-body">
<!-- 이미지 중심: 원본 다운로드 → 자막 추출 → 내보내기 한 번에 (NEW) -->
<div class="usage-guide">
<div class="ug-title">🎬 원본 다운로드 한 번에 <span class="ug-new">NEW</span></div>
<p class="ug-lead">유튜브에서 직접 받아 올릴 필요 없이, <b>버튼 한 번</b>으로 원본을 받아
<b>자동 자막 추출</b>하고 그대로 <b>컷편집·내보내기</b>까지 이어집니다.</p>
<div class="ug-step">
<span class="ug-badge">1</span>
<div class="ug-step-body">
<div class="ug-step-t">‘원본 다운로드’ 클릭</div>
<div class="ug-step-d">저장된 영상 URL로 <b>yt-dlp</b>가 원본을 받아 서버에 보관합니다.
URL을 따로 입력할 필요가 없어요. (언어가 틀리면 왼쪽 <b>자동감지</b>에서 ko/en/zh/ja 선택)</div>
<img class="ug-img" th:src="@{/img/usage/dl-1-button.png}" alt="원본 다운로드 버튼" loading="lazy">
</div>
</div>
<div class="ug-step">
<span class="ug-badge">2</span>
<div class="ug-step-body">
<div class="ug-step-t">자동 자막 추출 완료 → 시간 싱크 자막 생성</div>
<div class="ug-step-d">다운로드가 끝나면 <b>Whisper</b>가 바로 자막 추출해 <b>[00:00] 자막</b> 세그먼트가 만들어집니다.
줄을 클릭하면 영상이 그 지점으로 이동해요.</div>
<img class="ug-img" th:src="@{/img/usage/dl-2-segments.png}" alt="자동 생성된 시간 싱크 자막" loading="lazy">
</div>
</div>
<div class="ug-step">
<span class="ug-badge">3</span>
<div class="ug-step-body">
<div class="ug-step-t">내보내기 (업로드 불필요)</div>
<div class="ug-step-d"><b>말 없는 구간 제거</b>·<b>배속</b>을 적용하고 <b>SRT / 영상</b>을 내보내면 CapCut에 그대로 import.
렌더는 <b>방금 받은 원본을 그대로 재사용</b>하므로 다시 업로드할 필요가 없습니다.</div>
<img class="ug-img" th:src="@{/img/usage/dl-3-export.png}" alt="내보내기(배속·말없는구간 제거·SRT/영상)" loading="lazy">
</div>
</div>
<p class="ug-tip">💡 직접 받은 파일을 쓰고 싶으면 옆의 <b>‘영상 업로드·자막 추출’</b>로 올리면 됩니다(기존 방식 그대로).</p>
</div>
<div class="ug-divider"></div>
<div class="help-item"> <div class="help-item">
<div class="hi-ic"><i data-lucide="upload"></i></div> <div class="hi-ic"><i data-lucide="upload"></i></div>
<div><div class="hi-t">영상 업로드 · 자막 추출</div><div class="hi-d">영상 파일을 올리면 <b>Whisper</b>가 말소리를 <b>시간 싱크 자막</b>으로 추출합니다. 언어는 자동감지(틀리면 옆에서 ko/en/zh/ja 선택).</div></div> <div><div class="hi-t">영상 업로드 · 전사</div><div class="hi-d">영상 파일을 올리면 <b>Whisper</b>가 말소리를 <b>시간 싱크 자막</b>으로 추출합니다. 언어는 자동감지(틀리면 옆에서 ko/en/zh/ja 선택). URL자막은 YouTube 자막을 평문으로(시간 없음) 가져옵니다.</div></div>
</div> </div>
<div class="help-item"> <div class="help-item">
<div class="hi-ic"><i data-lucide="list-video"></i></div> <div class="hi-ic"><i data-lucide="list-video"></i></div>
@ -91,29 +50,9 @@
</div> </div>
</div> </div>
<style>
.usage-guide { margin-bottom: 6px; }
.ug-title { font-size: 1.05rem; font-weight: 800; display:flex; align-items:center; gap:8px; margin-bottom: 6px; }
.ug-new { font-size: 0.65rem; font-weight: 800; letter-spacing: 0.04em; color:#fff;
background:#f97316; padding: 2px 7px; border-radius: 999px; }
.ug-lead { color: var(--text-2); font-size: 0.9rem; line-height: 1.6; margin-bottom: 14px; }
.ug-step { display:flex; gap: 12px; margin-bottom: 16px; }
.ug-badge { flex-shrink:0; width: 26px; height: 26px; border-radius: 50%;
background:#f97316; color:#fff; font-weight: 800; font-size: 0.85rem;
display:flex; align-items:center; justify-content:center; margin-top: 1px; }
.ug-step-body { flex: 1; min-width: 0; }
.ug-step-t { font-weight: 700; font-size: 0.95rem; margin-bottom: 3px; }
.ug-step-d { color: var(--text-2); font-size: 0.85rem; line-height: 1.6; margin-bottom: 9px; }
.ug-img { display:block; width: 100%; border: 1px solid var(--glass-border);
border-radius: var(--radius-md); box-shadow: 0 2px 10px rgba(0,0,0,0.10); }
.ug-tip { font-size: 0.85rem; color: var(--text-2); background: var(--surface-2);
border: 1px solid var(--glass-border); border-radius: var(--radius-md); padding: 9px 12px; margin-top: 4px; }
.ug-divider { height:1px; background: var(--glass-border); margin: 18px 0; }
</style>
<div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;"> <div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;">
<!-- 좌: 영상 + 정보 (스크롤해도 상단에 따라다님) --> <!-- 좌: 영상 + 정보 -->
<div class="card" style="position:sticky; top:1rem; align-self:start;"> <div class="card">
<div id="player" style="width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; overflow:hidden; background:#000; margin-bottom:12px;"></div> <div id="player" style="width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; overflow:hidden; background:#000; margin-bottom:12px;"></div>
<video id="localVideo" controls playsinline <video id="localVideo" controls playsinline
style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video> style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video>
@ -132,29 +71,23 @@
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;"> <div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
<h3 class="text-lg font-bold">원본 스크립트</h3> <h3 class="text-lg font-bold">원본 스크립트</h3>
<div class="flex gap-2" style="flex-wrap:wrap; align-items:center;"> <div class="flex gap-2" style="flex-wrap:wrap; align-items:center;">
<select id="langSel" title="자막 추출 언어(자동감지가 틀릴 때 강제)" class="pub-in" style="width:auto; padding:7px 9px;"> <select id="langSel" title="전사 언어(자동감지가 틀릴 때 강제)" class="pub-in" style="width:auto; padding:7px 9px;">
<option value="">자동감지</option> <option value="">자동감지</option>
<option value="ko">한국어</option> <option value="ko">한국어</option>
<option value="en">English</option> <option value="en">English</option>
<option value="zh">中文</option> <option value="zh">中文</option>
<option value="ja">日本語</option> <option value="ja">日本語</option>
</select> </select>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="downloadBtn" onclick="downloadOriginal()" title="저장된 URL로 원본을 yt-dlp로 받아 자동 자막 추출(수동 다운로드+업로드 불필요)"> <label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;">
<i data-lucide="download" style="width:15px;"></i> 원본 다운로드 <i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
</button>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="transcribeCachedBtn" onclick="transcribeCached()" style="display:none;" title="받아둔 원본을 Whisper로 자막 추출(자막 추출만 다시 하기)">
<i data-lucide="captions" style="width:15px;"></i> 자막 추출(음성)
</button>
<label class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;" title="직접 받은 영상 파일을 올려 자막 추출">
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·자막 추출
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)"> <input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
</label> </label>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)">
<i data-lucide="download-cloud" style="width:15px;"></i> URL자막
</button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()"> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사 <i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
</button> </button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)">
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
</button>
</div> </div>
</div> </div>
@ -164,7 +97,7 @@
<div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:var(--surface-2);"></div> <div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:var(--surface-2);"></div>
<!-- 평문 폴백 (URL 자막 추출 시) --> <!-- 평문 폴백 (URL 자막 추출 시) -->
<textarea id="transcript" readonly placeholder="원본 다운로드’ 또는 ‘영상 업로드·자막 추출’로 영상에서 시간 싱크된 자막을 추출하세요." <textarea id="transcript" readonly placeholder="영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요."
style="width:100%; min-height:140px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text-2); outline:none; font-size:0.9rem; line-height:1.6;"></textarea> style="width:100%; min-height:140px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text-2); outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
<!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) --> <!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
@ -195,9 +128,6 @@
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()"> <button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()">
<i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기 <i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기
</button> </button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="koSrtBtn" onclick="exportKoSrt()" title="세그먼트를 한국어로 번역해 타임코드 맞춰 SRT로 내보내기">
<i data-lucide="languages" style="width:15px;"></i> 한국어 SRT
</button>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="renderBtn" onclick="renderVideo()"> <button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="renderBtn" onclick="renderVideo()">
<i data-lucide="scissors" style="width:15px;"></i> 영상 내보내기 <i data-lucide="scissors" style="width:15px;"></i> 영상 내보내기
</button> </button>
@ -215,44 +145,6 @@
.seg-text { color:var(--text-2); } .seg-text { color:var(--text-2); }
</style> </style>
<!-- AI 자막 (음성 Whisper / 화면 Gemini) -->
<div class="card">
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
<h3 class="text-lg font-bold">🤖 AI 자막 (음성 / 화면)</h3>
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="aiGenBtn" onclick="generateAiSubtitles()" title="왼쪽=음성(Whisper 정밀+한국어), 오른쪽=화면(Gemini 한국어). 음성은 먼저 '자막 추출' 필요">
<i data-lucide="sparkles" style="width:15px;"></i> 생성
</button>
</div>
<div id="aiStatus" class="text-sm mb-2" style="display:none;"></div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<div class="flex items-center justify-between mb-1" style="gap:6px;">
<div class="font-bold text-sm">🎙 음성 <span class="text-xs text-muted">Whisper·정밀</span></div>
<button class="btn btn-secondary" style="padding:4px 9px; font-size:0.8rem;" onclick="downloadAiSrt('audio')"><i data-lucide="file-down" style="width:13px;"></i> SRT</button>
</div>
<div id="aiAudioList" class="ai-list"><div class="ai-empty">‘생성’을 누르세요</div></div>
</div>
<div>
<div class="flex items-center justify-between mb-1" style="gap:6px;">
<div class="font-bold text-sm">🖼 화면 <span class="text-xs text-muted">Gemini·~1초</span></div>
<button class="btn btn-secondary" style="padding:4px 9px; font-size:0.8rem;" onclick="downloadAiSrt('screen')"><i data-lucide="file-down" style="width:13px;"></i> SRT</button>
</div>
<div id="aiScreenList" class="ai-list"><div class="ai-empty">‘생성’을 누르세요</div></div>
</div>
</div>
<style>
.ai-list { max-height:300px; overflow:auto; border:1px solid var(--glass-border);
border-radius:var(--radius-md); background:var(--surface-2); }
.ai-row { display:flex; gap:8px; padding:6px 10px; cursor:pointer; border-bottom:1px solid var(--surface);
font-size:0.85rem; line-height:1.5; }
.ai-row:hover { background:var(--surface); }
.ai-txt { display:flex; flex-direction:column; min-width:0; }
.ai-txt .seg-text { color:var(--text); }
.ai-orig { color:var(--text-2); font-size:0.78rem; opacity:0.8; margin-top:1px; }
.ai-empty { padding:14px 10px; color:var(--text-2); font-size:0.85rem; text-align:center; }
</style>
</div>
<!-- 재작성 에디터 --> <!-- 재작성 에디터 -->
<div class="card"> <div class="card">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
@ -342,11 +234,8 @@
} }
// ===== 세그먼트(영상 싱크) ===== // ===== 세그먼트(영상 싱크) =====
let SEGMENTS = []; // 자막 추출 원본 세그먼트(원본 영상 재생에 싱크) let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용) let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트
let AI_SCREEN = []; // AI 자막 - 화면(Gemini 한국어) 세그먼트
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부 let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트 let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
@ -364,22 +253,6 @@
return (v && v.src) ? v : null; return (v && v.src) ? v : null;
} }
// 받아둔 원본 mp4를 왼쪽 플레이어에 싣는다(Range 스트리밍) → 세그먼트 클릭 시 그 지점 재생.
function showServerVideoInPlayer(){
const v = document.getElementById('localVideo');
const src = location.origin + API + '/' + VIDEO_ID + '/download/file';
if(v.getAttribute('src') !== src){ v.src = src; }
v.style.display = 'block';
document.getElementById('player').style.display = 'none';
}
// 원본이 없을 때 YouTube 임베드로 되돌린다.
function showYoutubeInPlayer(){
const v = document.getElementById('localVideo');
v.removeAttribute('src'); if(v.load) v.load();
v.style.display = 'none';
document.getElementById('player').style.display = 'block';
}
function renderSegments(segs){ function renderSegments(segs){
SEGMENTS = Array.isArray(segs) ? segs : []; SEGMENTS = Array.isArray(segs) ? segs : [];
trimApplied = false; REMAPPED = []; trimApplied = false; REMAPPED = [];
@ -414,15 +287,7 @@
rows.forEach(r=>{ rows.forEach(r=>{
const on = Number(r.dataset.i)===activeIdx; const on = Number(r.dataset.i)===activeIdx;
r.classList.toggle('active', on); r.classList.toggle('active', on);
if(on){ if(on) r.scrollIntoView({block:'nearest'});
// 페이지 전체가 아니라 세그먼트 박스 안에서만 스크롤(영상 sticky 흔들림 방지)
const box = document.getElementById('segmentList');
if(box){
const br = box.getBoundingClientRect(), rr = r.getBoundingClientRect();
if(rr.top < br.top) box.scrollTop -= (br.top - rr.top);
else if(rr.bottom > br.bottom) box.scrollTop += (rr.bottom - br.bottom);
}
}
}); });
} }
@ -452,87 +317,6 @@
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
} }
// 세그먼트를 한국어로 번역해(서버 일괄번역) 기존 타임라인(배속·말없는구간 제거 반영)에 끼워 SRT 생성.
async function exportKoSrt(){
const btn = document.getElementById('koSrtBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
try {
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
const ko = (r && r.texts) || [];
if(!ko.length){ alert('번역할 세그먼트가 없습니다. 먼저 자막 추출하세요.'); return; }
// 현재 작업 세그먼트(원본/무음제거 후)와 1:1 정렬 → 텍스트만 한국어로 교체(타임코드·배속 그대로)
const segs = workingSegments();
const koSegs = segs.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')) }));
const blob = new Blob([buildSrt(koSegs, curSpeed())], {type:'application/x-subrip'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'script_ko_' + VIDEO_ID + '.srt';
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
} catch(e){ alert('한국어 SRT 실패: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
}
// ===== AI 자막 (음성 Whisper / 화면 Gemini) =====
// segs 있으면 목록, 없으면 error(빨강) 또는 note(회색 안내) 표시.
function renderAiPanel(elId, segs, error, note){
const box = document.getElementById(elId);
if(segs && segs.length){
box.innerHTML = segs.map(sg =>
'<div class="ai-row" data-start="' + sg.start + '" onclick="seekTo(' + sg.start + ')">'
+ '<span class="seg-time">' + mmss(sg.start) + '</span>'
+ '<span class="ai-txt">'
+ '<span class="seg-text">' + esc(sg.text) + '</span>'
+ (sg.original ? '<span class="ai-orig">' + esc(sg.original) + '</span>' : '')
+ '</span></div>').join('');
return;
}
if(error){ box.innerHTML = '<div class="ai-empty" style="color:#f87171;">' + esc(error) + '</div>'; return; }
box.innerHTML = '<div class="ai-empty">' + esc(note || '자막 없음') + '</div>';
}
// 음성(Whisper 정밀+한국어) + 화면(Gemini 한국어)을 동시에 생성해 좌우 패널에 표시.
async function generateAiSubtitles(){
const btn = document.getElementById('aiGenBtn');
const status = document.getElementById('aiStatus');
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '생성 중…';
status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = 'AI 자막 생성 중… (음성 번역 + Gemini 화면분석, 영상 길이에 따라 시간 걸림)';
// 왼쪽: 저장된 Whisper 세그먼트 → 한국어 번역(LibreTranslate)
const audioP = (async () => {
// 말소리가 없는(음악·화면자막만) 영상은 오류가 아니라 '음성 없음' 안내.
if(!SEGMENTS || !SEGMENTS.length) return { note: '음성 없음 — 말소리가 없거나(음악·화면자막만) ‘자막 추출’ 미실행' };
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko&format=segments', { method:'POST' });
const ko = (r && r.texts) || [];
return { segments: SEGMENTS.map((sg, i) => ({ start: sg.start, end: sg.end, text: (ko[i] != null ? ko[i] : (sg.text || '')), original: sg.text })) };
})().catch(e => ({ error: '음성 번역 실패: ' + e.message }));
// 오른쪽: Gemini 화면 자막(한국어)
const screenP = api(API + '/' + VIDEO_ID + '/gemini-subtitles', { method:'POST' })
.then(r => ({ segments: (r && r.segments) || [] }))
.catch(e => ({ error: 'Gemini 실패: ' + e.message }));
const [audio, screen] = await Promise.all([audioP, screenP]);
AI_AUDIO = audio.segments || [];
AI_SCREEN = screen.segments || [];
renderAiPanel('aiAudioList', AI_AUDIO, audio.error, audio.note);
renderAiPanel('aiScreenList', AI_SCREEN, screen.error, screen.note);
status.style.color = '#4ade80';
status.textContent = '완료 · 음성 ' + AI_AUDIO.length + '개 · 화면 ' + AI_SCREEN.length + '개'
+ (audio.error ? ' · ⚠️음성:' + audio.error : '') + (screen.error ? ' · ⚠️화면:' + screen.error : '');
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
}
// 음성/화면 패널의 한국어 세그먼트를 SRT로 다운로드.
function downloadAiSrt(which){
const segs = which === 'audio' ? AI_AUDIO : AI_SCREEN;
if(!segs || !segs.length){ alert('먼저 ‘생성’을 눌러 ' + (which==='audio'?'음성':'화면') + ' 자막을 만드세요.'); return; }
const blob = new Blob([buildSrt(segs, 1.0)], {type:'application/x-subrip'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = (which==='audio'?'audio_ko_':'screen_ko_') + VIDEO_ID + '.srt';
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
}
// ===== 말 없는 구간 제거 ===== // ===== 말 없는 구간 제거 =====
function onTrimToggle(){ function onTrimToggle(){
if(document.getElementById('trimOn').checked){ previewTrim(); } if(document.getElementById('trimOn').checked){ previewTrim(); }
@ -565,7 +349,7 @@
} }
async function renderVideo(){ async function renderVideo(){
if(!UPLOADED_FILE && !HAS_SERVER_FILE){ alert('먼저 "원본 다운로드" 또는 "영상 업로드·자막 추출"를 실행하세요. (렌더에는 원본 영상이 필요합니다)'); return; } if(!UPLOADED_FILE){ alert('먼저 영상을 업로드·전사하세요. (렌더에는 원본 영상 파일이 필요합니다)'); return; }
const pad = parseFloat(document.getElementById('trimPad').value) || 0; const pad = parseFloat(document.getElementById('trimPad').value) || 0;
const gap = parseFloat(document.getElementById('trimGap').value) || 0; const gap = parseFloat(document.getElementById('trimGap').value) || 0;
const speed = curSpeed(); const speed = curSpeed();
@ -576,7 +360,7 @@
status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)'; status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)';
try { try {
const fd = new FormData(); const fd = new FormData();
if(UPLOADED_FILE) fd.append('file', UPLOADED_FILE); // 없으면 서버 캐시(원본 다운로드본) 사용 fd.append('file', UPLOADED_FILE);
fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed); fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed);
const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd }); const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd });
if(!res.ok){ if(!res.ok){
@ -598,123 +382,10 @@
} }
} }
// 저장된 URL로 원본을 서버에서 yt-dlp로 받아 자동 자막 추출. 성공 시 렌더도 업로드 없이 가능.
async function downloadOriginal(){
const status = document.getElementById('transcribeStatus');
const btn = document.getElementById('downloadBtn');
const orig = btn.innerHTML;
btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…';
status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)';
// 1단계: 다운로드(빠름). 실패하면 여기서 종료.
try {
const d = await api(API + '/' + VIDEO_ID + '/download', { method:'POST' });
UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보)
updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : '';
status.style.color = '#4ade80';
status.textContent = '다운로드 완료 ✓ ' + mb + ' · 자막 추출 중…';
} catch(e){
status.style.color = '#f87171';
status.textContent = '다운로드 실패: ' + e.message;
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
return;
}
// 2단계: 자막 추출(분리). 실패해도 다운로드(영상)는 유지 → '자막 추출' 버튼으로 재시도 가능.
try {
const s = await runTranscribeCached();
status.style.color = '#4ade80';
status.textContent = '다운로드+자막 추출 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){
status.style.color = '#fbbf24';
status.textContent = '다운로드는 완료 ✓ · 자막 추출 실패(서버 혼잡일 수 있음): ' + e.message
+ ' — ‘자막 추출’ 버튼으로 다시 시도하세요';
} finally {
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}
}
// 받아둔 원본을 Whisper 로 자막 추출(다운로드와 분리). 세그먼트를 렌더하고 응답을 반환.
async function runTranscribeCached(){
const lang = document.getElementById('langSel').value;
const url = API + '/' + VIDEO_ID + '/transcribe-cached' + (lang ? ('?language=' + lang) : '');
const s = await api(url, { method:'POST' });
renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || '';
return s;
}
// '자막 추출' 버튼: 받아둔 원본만 다시 자막 추출(자막 추출만 실패했을 때 재시도용).
async function transcribeCached(){
const status = document.getElementById('transcribeStatus');
const btn = document.getElementById('transcribeCachedBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '자막 추출 중…';
status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = '자막 추출 중… (Shorts 기준 보통 20~60초, 서버 혼잡 시 더 걸림)';
try {
const s = await runTranscribeCached();
status.style.color = '#4ade80';
status.textContent = '자막 추출 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){
status.style.color = '#f87171';
status.textContent = '자막 추출 실패: ' + e.message;
} finally {
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}
}
// 서버 캐시 보유 상태 → '받은 원본 삭제' 버튼 표시 + 렌더 캐시 사용 여부(HAS_SERVER_FILE) 갱신
function updateCacheUI(cached, sizeBytes){
HAS_SERVER_FILE = !!cached;
// 받아진 상태면 '원본 다운로드' 숨기고 '자막 추출'·'받은 원본 삭제' 노출(없으면 반대)
const dl = document.getElementById('downloadBtn');
const tr = document.getElementById('transcribeCachedBtn');
const del = document.getElementById('deleteDlBtn');
if(dl) dl.style.display = cached ? 'none' : '';
if(tr) tr.style.display = cached ? '' : 'none';
if(del){
del.style.display = cached ? '' : 'none';
if(cached && sizeBytes){
del.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)';
}
}
// 업로드본이 없을 때만 플레이어를 서버 원본/YouTube로 전환(업로드본이 우선)
if(!UPLOADED_FILE){
if(cached) showServerVideoInPlayer();
else showYoutubeInPlayer();
}
}
// 받아둔 원본 mp4 삭제(자막/세그먼트는 유지)
async function deleteDownload(){
if(!confirm('서버에 받아둔 원본 영상(mp4)을 삭제할까요?\n자막·세그먼트는 그대로 유지됩니다.')) return;
const btn = document.getElementById('deleteDlBtn');
const status = document.getElementById('transcribeStatus');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '삭제 중…';
try {
const r = await api(API + '/' + VIDEO_ID + '/download', { method:'DELETE' });
updateCacheUI(false, 0);
status.style.display = 'block'; status.style.color = '#9ca3af';
status.textContent = r && r.deleted ? '받은 원본을 삭제했습니다. (다시 받으려면 ‘원본 다운로드’)' : '삭제할 원본이 없습니다.';
} catch(e){
status.style.display = 'block'; status.style.color = '#f87171';
status.textContent = '원본 삭제 실패: ' + e.message;
} finally {
btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
}
}
async function onVideoSelected(ev){ async function onVideoSelected(ev){
const file = ev.target.files && ev.target.files[0]; const file = ev.target.files && ev.target.files[0];
if(!file) return; if(!file) return;
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관(업로드본이 서버 캐시보다 우선) UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김 // 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
const v = document.getElementById('localVideo'); const v = document.getElementById('localVideo');
@ -722,11 +393,11 @@
v.style.display = 'block'; v.style.display = 'block';
document.getElementById('player').style.display = 'none'; document.getElementById('player').style.display = 'none';
// 2) 서버로 보내 Whisper 자막 추출 // 2) 서버로 보내 Whisper 전사
const status = document.getElementById('transcribeStatus'); const status = document.getElementById('transcribeStatus');
const btn = document.getElementById('uploadBtn'); const btn = document.getElementById('uploadBtn');
status.style.display = 'block'; status.style.color = '#facc15'; status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = '자막 추출 중… (Shorts 기준 보통 20~60초)'; status.textContent = '전사 중… (Shorts 기준 보통 20~60초)';
btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6';
try { try {
const fd = new FormData(); const fd = new FormData();
@ -737,12 +408,12 @@
renderSegments(s.segments); renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || ''; document.getElementById('transcript').value = s.transcript || '';
status.style.color = '#4ade80'; status.style.color = '#4ade80';
status.textContent = '자막 추출 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트' status.textContent = '전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '') + (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : ''); + (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){ } catch(e){
status.style.color = '#f87171'; status.style.color = '#f87171';
status.textContent = '자막 추출 실패: ' + e.message; status.textContent = '전사 실패: ' + e.message;
} finally { } finally {
btn.style.pointerEvents = ''; btn.style.opacity = ''; btn.style.pointerEvents = ''; btn.style.opacity = '';
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
@ -772,12 +443,6 @@
renderSegments(s.segments); renderSegments(s.segments);
} catch(e){ /* ignore */ } } catch(e){ /* ignore */ }
// 받아둔 원본(서버 캐시) 상태 → 삭제버튼/렌더 캐시 사용 갱신(이전 세션에 받아둔 것도 반영)
try {
const d = await api(API + '/' + VIDEO_ID + '/download');
updateCacheUI(!!d.cached, d.sizeBytes || 0);
} catch(e){ /* ignore */ }
// 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트 // 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트
const lv = document.getElementById('localVideo'); const lv = document.getElementById('localVideo');
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime)); lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));
@ -785,6 +450,17 @@
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }
async function extractScript(){
const btn = document.getElementById('extractBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '추출 중...';
try {
const s = await api(API + '/' + VIDEO_ID + '/extract-script', { method:'POST' });
document.getElementById('transcript').value = s.transcript || '';
if(!s.transcript) alert('자막을 가져오지 못했습니다. (영상에 자막이 없거나 외부 서비스 차단)');
} catch(e){ alert('추출 실패: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
}
function copyToEditor(){ function copyToEditor(){
const t = document.getElementById('transcript').value; const t = document.getElementById('transcript').value;
if(!t){ alert('복사할 스크립트가 없습니다.'); return; } if(!t){ alert('복사할 스크립트가 없습니다.'); return; }
@ -868,9 +544,6 @@
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); } function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); }); document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
// ?help=1 딥링크로 들어오면 사용법을 바로 펼친다(공유/안내용).
if (new URLSearchParams(location.search).get('help') === '1') openHelp();
(async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })(); (async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })();
/*]]>*/ /*]]>*/
</script> </script>

View File

@ -1,55 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class DiscoveryRankerTest {
private YoutubeSearchResultDto item(String ch, long subs, String vid, long views) {
YoutubeSearchResultDto d = new YoutubeSearchResultDto();
d.setChannelId(ch);
d.setChannelTitle(ch + "_title");
d.setThumbnailUrl("thumb_" + vid);
d.setSubscriberCount(subs);
d.setVideoId(vid);
d.setTitle("v_" + vid);
d.setViewCount(views);
d.setChannelCountry("KR");
return d;
}
@Test
void rank_채널별_최고배율_선정_필터_정렬() {
List<YoutubeSearchResultDto> items = List.of(
item("A", 1000, "a1", 50000), // 배율 50 (작은채널·고배율)
item("A", 1000, "a2", 10000), // 배율 10 (A의 비대표)
item("B", 5_000_000, "b1", 60_000_000), // 구독자 과다 제외(maxSubs)
item("C", 2000, "c1", 4000), // 배율 2 minRatio 미달 제외
item("D", 1000, "d1", 30000) // 배율 30
);
List<DiscoveryRanker.Candidate> out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of());
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("A", "D");
assertThat(out.get(0).topVideoId()).isEqualTo("a1");
assertThat(out.get(0).ratio()).isEqualTo(50.0);
}
@Test
void rank_제외채널_및_구독자0_제거() {
List<YoutubeSearchResultDto> items = List.of(
item("A", 1000, "a1", 50000), // 배율 50 이지만 제외목록
item("E", 0, "e1", 9999), // 구독자 0 배율 계산 불가 제외
item("F", 1000, "f1", 20000) // 배율 20
);
List<DiscoveryRanker.Candidate> out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A"));
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F");
}
}

View File

@ -1,72 +0,0 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* yt-dlp 명령 인자 조립({@link VideoDownloadService#buildCommand}) videoId 검증의 순수 로직 테스트.
* 프로세스 실행 없이 검증 가능한 부분만 다룬다.
*/
class VideoDownloadServiceTest {
@Test
void buildCommand_정상videoId면_ytdlp_인자를_조립한다() {
List<String> cmd = VideoDownloadService.buildCommand(
"yt-dlp", "", 1080, "dQw4w9WgXcQ", Path.of("downloads", "5.mp4"));
assertThat(cmd).startsWith("yt-dlp");
assertThat(cmd).contains("--no-playlist");
assertThat(cmd).containsSequence("-f", "bv*[height<=1080]+ba/b[height<=1080]/b");
assertThat(cmd).containsSequence("--merge-output-format", "mp4");
assertThat(cmd).contains("-o");
assertThat(cmd).endsWith("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
// ffmpeg-location 비어있으면 해당 플래그는 빠진다
assertThat(cmd).doesNotContain("--ffmpeg-location");
}
@Test
void buildCommand_ffmpeg경로가_있으면_플래그를_포함하고_maxHeight를_반영한다() {
List<String> cmd = VideoDownloadService.buildCommand(
"yt-dlp", "D:\\utils\\ffmpeg\\bin", 720, "dQw4w9WgXcQ", Path.of("out.mp4"));
assertThat(cmd).containsSequence("--ffmpeg-location", "D:\\utils\\ffmpeg\\bin");
assertThat(cmd).containsSequence("-f", "bv*[height<=720]+ba/b[height<=720]/b");
}
@Test
void buildCommand_잘못된videoId면_예외를_던진다() {
// 메타문자(인젝션 시도)
assertThatThrownBy(() -> VideoDownloadService.buildCommand(
"yt-dlp", "", 1080, "; rm -rf /", Path.of("x.mp4")))
.isInstanceOf(IllegalArgumentException.class);
// 길이 부족
assertThatThrownBy(() -> VideoDownloadService.buildCommand(
"yt-dlp", "", 1080, "short", Path.of("x.mp4")))
.isInstanceOf(IllegalArgumentException.class);
// null
assertThatThrownBy(() -> VideoDownloadService.buildCommand(
"yt-dlp", "", 1080, null, Path.of("x.mp4")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void deleteCache_파일이_있으면_지우고true_없으면false(@TempDir Path tmp) throws Exception {
VideoDownloadService svc = new VideoDownloadService(null); // deleteCache repo 미사용
ReflectionTestUtils.setField(svc, "downloadDir", tmp.toString());
Path file = tmp.resolve("77.mp4");
Files.writeString(file, "dummy");
assertThat(svc.deleteCache(77L)).isTrue(); // 있으니 삭제
assertThat(Files.exists(file)).isFalse();
assertThat(svc.deleteCache(77L)).isFalse(); // 이미 없음
}
}

View File

@ -1,69 +0,0 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/** Gemini 요청 조립 / 응답 파싱의 순수 로직 테스트(HTTP 없음). */
class GeminiSubtitleServiceTest {
private final ObjectMapper om = new ObjectMapper();
@Test
void buildRequest_youtubeURL와_프롬프트와_json응답형식을_담는다() throws Exception {
Map<String, Object> req = GeminiSubtitleService.buildRequest(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", "화면 자막 한국어로");
String json = om.writeValueAsString(req);
assertThat(json)
.contains("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.contains("file_uri")
.contains("화면 자막 한국어로")
.contains("application/json"); // responseMimeType
}
@Test
void extractSegments_정상응답의_JSON배열을_세그먼트로_원문포함() {
String resp = "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":"
+ "\"[{\\\"start\\\":0.0,\\\"end\\\":3.0,\\\"original\\\":\\\"こんにちは\\\",\\\"text\\\":\\\"안녕하세요\\\"},"
+ "{\\\"start\\\":3.0,\\\"end\\\":5.5,\\\"text\\\":\\\"반갑습니다\\\"}]\"}]}}]}";
List<ScriptResponseDto.Segment> segs = GeminiSubtitleService.extractSegments(om, resp);
assertThat(segs).hasSize(2);
assertThat(segs.get(0).getStart()).isEqualTo(0.0);
assertThat(segs.get(0).getEnd()).isEqualTo(3.0);
assertThat(segs.get(0).getOriginal()).isEqualTo("こんにちは");
assertThat(segs.get(0).getText()).isEqualTo("안녕하세요");
assertThat(segs.get(1).getText()).isEqualTo("반갑습니다");
}
@Test
void extractSegments_코드펜스로_감싼_응답도_파싱() {
String resp = "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":"
+ "\"```json\\n[{\\\"start\\\":1,\\\"end\\\":2,\\\"text\\\":\\\"테스트\\\"}]\\n```\"}]}}]}";
List<ScriptResponseDto.Segment> segs = GeminiSubtitleService.extractSegments(om, resp);
assertThat(segs).hasSize(1);
assertThat(segs.get(0).getText()).isEqualTo("테스트");
}
@Test
void sanitizeApiKey_앞의_등호와_공백을_정리() {
assertThat(GeminiSubtitleService.sanitizeApiKey(" =AIzaKEY ")).isEqualTo("AIzaKEY");
assertThat(GeminiSubtitleService.sanitizeApiKey("==AIzaKEY")).isEqualTo("AIzaKEY");
assertThat(GeminiSubtitleService.sanitizeApiKey("AIzaKEY")).isEqualTo("AIzaKEY");
assertThat(GeminiSubtitleService.sanitizeApiKey(null)).isEqualTo("");
}
@Test
void extractSegments_깨진응답이면_빈리스트() {
assertThat(GeminiSubtitleService.extractSegments(om, "{}")).isEmpty();
assertThat(GeminiSubtitleService.extractSegments(om,
"{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"이건 JSON 아님\"}]}}]}")).isEmpty();
}
}

View File

@ -1,68 +0,0 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** LibreTranslate 요청 조립/응답 파싱의 순수 로직 테스트(HTTP 없음). */
class TranslateServiceTest {
private final ObjectMapper om = new ObjectMapper();
@Test
void buildRequestBody_소스가_있으면_그대로_없으면_auto() {
Map<String, String> withSrc = TranslateService.buildRequestBody("Hello", "en", "ko");
assertThat(withSrc).containsEntry("q", "Hello")
.containsEntry("source", "en")
.containsEntry("target", "ko")
.containsEntry("format", "text");
Map<String, String> autoSrc = TranslateService.buildRequestBody("Hello", " ", "ko");
assertThat(autoSrc).containsEntry("source", "auto");
Map<String, String> nullSrc = TranslateService.buildRequestBody("Hello", null, "ko");
assertThat(nullSrc).containsEntry("source", "auto");
}
@Test
void extractTranslatedText_정상응답에서_번역문을_뽑는다() {
String json = "{\"translatedText\":\"안녕하세요\"}";
assertThat(TranslateService.extractTranslatedText(om, json)).isEqualTo("안녕하세요");
}
@Test
void extractTranslatedText_필드없으면_빈문자열() {
assertThat(TranslateService.extractTranslatedText(om, "{}")).isEmpty();
}
@Test
void extractTranslatedText_깨진json이면_예외() {
assertThatThrownBy(() -> TranslateService.extractTranslatedText(om, "not-json"))
.isInstanceOf(RuntimeException.class);
}
@Test
void buildBatchRequestBody_q를_배열로_담고_source_정규화() {
Map<String, Object> b = TranslateService.buildBatchRequestBody(java.util.List.of("a", "b"), null, "ko");
assertThat(b.get("q")).isEqualTo(java.util.List.of("a", "b"));
assertThat(b.get("source")).isEqualTo("auto");
assertThat(b.get("target")).isEqualTo("ko");
assertThat(b.get("format")).isEqualTo("text");
}
@Test
void extractTranslatedTexts_배열응답을_리스트로() {
String json = "{\"translatedText\":[\"안녕\",\"세계\"]}";
assertThat(TranslateService.extractTranslatedTexts(om, json)).containsExactly("안녕", "세계");
}
@Test
void extractTranslatedTexts_문자열응답이면_단일원소리스트() {
assertThat(TranslateService.extractTranslatedTexts(om, "{\"translatedText\":\"안녕\"}"))
.containsExactly("안녕");
}
}

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More