Compare commits
42 Commits
8ec6268a1e
...
db8452d1ea
| Author | SHA1 | Date | |
|---|---|---|---|
| db8452d1ea | |||
| 24f964364a | |||
| 84155a69f8 | |||
| fec4c77cb9 | |||
| f3c6d20442 | |||
| fd18a8981a | |||
| 1d7d50f50b | |||
| 08d50230c4 | |||
| 3bfb7a7495 | |||
| e9b192eabb | |||
| 35944a8fd6 | |||
| 02fa5be25b | |||
| 507ae7c760 | |||
| ee4d556851 | |||
| 9fed1e04b9 | |||
| 279cb4c9d0 | |||
| 0decc4439c | |||
| d16f23a4c0 | |||
| b151204267 | |||
| 99dbe80fdb | |||
| 31b7720af9 | |||
| 7b94515180 | |||
| 6380a28a6f | |||
| 447645c19a | |||
| c44b16a7f4 | |||
| aa8dc17dd8 | |||
| 7ebee1caa3 | |||
| fbce1ec8ea | |||
| 32b2eae62c | |||
| bba81760a2 | |||
| 6a0a7294bb | |||
| f7ac96f1dd | |||
| a2934af1a3 | |||
| d90ee7c09a | |||
| e9ad800134 | |||
| 198334c6f8 | |||
| 77e77894c9 | |||
| ff9609c9d1 | |||
| 986dcffd55 | |||
| f4ac1be171 | |||
| 26b8be1ff1 | |||
| a177b0c987 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,6 +15,9 @@ src/main/resources/application-local.yml
|
||||
tokens/
|
||||
*.log
|
||||
|
||||
# yt-dlp 다운로드 캐시(영상 파일은 커밋하지 않음)
|
||||
/downloads/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
6816
.playwright-mcp/page-2026-06-01T05-12-18-595Z.yml
Normal file
6816
.playwright-mcp/page-2026-06-01T05-12-18-595Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
54
.playwright-mcp/page-2026-06-01T08-29-18-177Z.yml
Normal file
54
.playwright-mcp/page-2026-06-01T08-29-18-177Z.yml
Normal file
@ -0,0 +1,54 @@
|
||||
- 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: 새로고침
|
||||
112
.playwright-mcp/page-2026-06-04T01-48-33-926Z.yml
Normal file
112
.playwright-mcp/page-2026-06-04T01-48-33-926Z.yml
Normal file
@ -0,0 +1,112 @@
|
||||
- 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]
|
||||
167
.playwright-mcp/page-2026-06-12T12-58-39-869Z.yml
Normal file
167
.playwright-mcp/page-2026-06-12T12-58-39-869Z.yml
Normal file
@ -0,0 +1,167 @@
|
||||
- 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%
|
||||
170
.playwright-mcp/page-2026-06-12T13-13-47-956Z.yml
Normal file
170
.playwright-mcp/page-2026-06-12T13-13-47-956Z.yml
Normal file
@ -0,0 +1,170 @@
|
||||
- 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%
|
||||
115
.playwright-mcp/page-2026-06-12T13-23-49-415Z.yml
Normal file
115
.playwright-mcp/page-2026-06-12T13-23-49-415Z.yml
Normal file
@ -0,0 +1,115 @@
|
||||
- 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]
|
||||
223
.playwright-mcp/page-2026-06-12T13-24-02-992Z.yml
Normal file
223
.playwright-mcp/page-2026-06-12T13-24-02-992Z.yml
Normal file
@ -0,0 +1,223 @@
|
||||
- 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%)
|
||||
115
.playwright-mcp/page-2026-06-12T13-24-31-135Z.yml
Normal file
115
.playwright-mcp/page-2026-06-12T13-24-31-135Z.yml
Normal file
@ -0,0 +1,115 @@
|
||||
- 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]
|
||||
223
.playwright-mcp/page-2026-06-12T13-24-40-073Z.yml
Normal file
223
.playwright-mcp/page-2026-06-12T13-24-40-073Z.yml
Normal file
@ -0,0 +1,223 @@
|
||||
- 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%)
|
||||
115
.playwright-mcp/page-2026-06-12T13-34-52-921Z.yml
Normal file
115
.playwright-mcp/page-2026-06-12T13-34-52-921Z.yml
Normal file
@ -0,0 +1,115 @@
|
||||
- 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]
|
||||
21668
.playwright-mcp/page-2026-06-12T13-35-02-383Z.yml
Normal file
21668
.playwright-mcp/page-2026-06-12T13-35-02-383Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
115
.playwright-mcp/page-2026-06-12T13-35-44-325Z.yml
Normal file
115
.playwright-mcp/page-2026-06-12T13-35-44-325Z.yml
Normal file
@ -0,0 +1,115 @@
|
||||
- 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]
|
||||
21668
.playwright-mcp/page-2026-06-12T13-35-55-338Z.yml
Normal file
21668
.playwright-mcp/page-2026-06-12T13-35-55-338Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
115
.playwright-mcp/page-2026-06-12T13-38-37-526Z.yml
Normal file
115
.playwright-mcp/page-2026-06-12T13-38-37-526Z.yml
Normal file
@ -0,0 +1,115 @@
|
||||
- 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]
|
||||
21668
.playwright-mcp/page-2026-06-12T13-38-48-317Z.yml
Normal file
21668
.playwright-mcp/page-2026-06-12T13-38-48-317Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
108
.playwright-mcp/page-2026-06-12T13-44-07-629Z.yml
Normal file
108
.playwright-mcp/page-2026-06-12T13-44-07-629Z.yml
Normal file
@ -0,0 +1,108 @@
|
||||
- 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]
|
||||
21661
.playwright-mcp/page-2026-06-12T13-44-17-831Z.yml
Normal file
21661
.playwright-mcp/page-2026-06-12T13-44-17-831Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
112
.playwright-mcp/page-2026-06-12T14-01-33-918Z.yml
Normal file
112
.playwright-mcp/page-2026-06-12T14-01-33-918Z.yml
Normal file
@ -0,0 +1,112 @@
|
||||
- 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]
|
||||
21665
.playwright-mcp/page-2026-06-12T14-01-44-191Z.yml
Normal file
21665
.playwright-mcp/page-2026-06-12T14-01-44-191Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
112
.playwright-mcp/page-2026-06-12T14-05-01-315Z.yml
Normal file
112
.playwright-mcp/page-2026-06-12T14-05-01-315Z.yml
Normal file
@ -0,0 +1,112 @@
|
||||
- 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]
|
||||
112
.playwright-mcp/page-2026-06-12T14-07-39-179Z.yml
Normal file
112
.playwright-mcp/page-2026-06-12T14-07-39-179Z.yml
Normal file
@ -0,0 +1,112 @@
|
||||
- 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]
|
||||
110
.playwright-mcp/page-2026-06-12T14-10-56-000Z.yml
Normal file
110
.playwright-mcp/page-2026-06-12T14-10-56-000Z.yml
Normal file
@ -0,0 +1,110 @@
|
||||
- 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]
|
||||
21663
.playwright-mcp/page-2026-06-12T14-11-08-211Z.yml
Normal file
21663
.playwright-mcp/page-2026-06-12T14-11-08-211Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
61
.playwright-mcp/page-2026-06-12T21-05-28-114Z.yml
Normal file
61
.playwright-mcp/page-2026-06-12T21-05-28-114Z.yml
Normal file
@ -0,0 +1,61 @@
|
||||
- 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: 새로고침
|
||||
8096
.playwright-mcp/page-2026-06-12T21-05-37-356Z.yml
Normal file
8096
.playwright-mcp/page-2026-06-12T21-05-37-356Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
75
.playwright-mcp/page-2026-06-12T21-05-57-538Z.yml
Normal file
75
.playwright-mcp/page-2026-06-12T21-05-57-538Z.yml
Normal file
@ -0,0 +1,75 @@
|
||||
- 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]
|
||||
157
.playwright-mcp/page-2026-06-12T21-06-34-495Z.yml
Normal file
157
.playwright-mcp/page-2026-06-12T21-06-34-495Z.yml
Normal file
@ -0,0 +1,157 @@
|
||||
- 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
|
||||
159
.playwright-mcp/page-2026-06-12T21-06-42-456Z.yml
Normal file
159
.playwright-mcp/page-2026-06-12T21-06-42-456Z.yml
Normal file
@ -0,0 +1,159 @@
|
||||
- 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
|
||||
107
.playwright-mcp/page-2026-06-12T21-06-59-639Z.yml
Normal file
107
.playwright-mcp/page-2026-06-12T21-06-59-639Z.yml
Normal file
@ -0,0 +1,107 @@
|
||||
- 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]
|
||||
3106
.playwright-mcp/page-2026-06-12T21-07-15-874Z.yml
Normal file
3106
.playwright-mcp/page-2026-06-12T21-07-15-874Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
106
.playwright-mcp/page-2026-06-12T21-22-17-686Z.yml
Normal file
106
.playwright-mcp/page-2026-06-12T21-22-17-686Z.yml
Normal file
@ -0,0 +1,106 @@
|
||||
- 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]
|
||||
3105
.playwright-mcp/page-2026-06-12T21-22-24-939Z.yml
Normal file
3105
.playwright-mcp/page-2026-06-12T21-22-24-939Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
143
.playwright-mcp/page-2026-06-12T21-23-07-466Z.yml
Normal file
143
.playwright-mcp/page-2026-06-12T21-23-07-466Z.yml
Normal file
@ -0,0 +1,143 @@
|
||||
- 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]: "世界で見つけたいろんな話をシェアします。 本動画の一部映像は、その魅力を広く伝える目的で引用しております。 原作者の皆さまには深く敬意を払っており、ご迷惑をおかけする意図はございません。 万が一問題がございましたら、下記メールまでご連絡ください。誠意をもって対応いたします。 公式Instagram:https://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
|
||||
143
.playwright-mcp/page-2026-06-12T21-23-14-831Z.yml
Normal file
143
.playwright-mcp/page-2026-06-12T21-23-14-831Z.yml
Normal file
@ -0,0 +1,143 @@
|
||||
- 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]: "世界で見つけたいろんな話をシェアします。 本動画の一部映像は、その魅力を広く伝える目的で引用しております。 原作者の皆さまには深く敬意を払っており、ご迷惑をおかけする意図はございません。 万が一問題がございましたら、下記メールまでご連絡ください。誠意をもって対応いたします。 公式Instagram:https://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
|
||||
104
.playwright-mcp/page-2026-06-12T21-31-49-036Z.yml
Normal file
104
.playwright-mcp/page-2026-06-12T21-31-49-036Z.yml
Normal file
@ -0,0 +1,104 @@
|
||||
- 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]
|
||||
3103
.playwright-mcp/page-2026-06-12T21-31-56-485Z.yml
Normal file
3103
.playwright-mcp/page-2026-06-12T21-31-56-485Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
277
.playwright-mcp/page-2026-06-12T21-32-37-792Z.yml
Normal file
277
.playwright-mcp/page-2026-06-12T21-32-37-792Z.yml
Normal file
@ -0,0 +1,277 @@
|
||||
- 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]
|
||||
277
.playwright-mcp/page-2026-06-12T21-32-47-860Z.yml
Normal file
277
.playwright-mcp/page-2026-06-12T21-32-47-860Z.yml
Normal file
@ -0,0 +1,277 @@
|
||||
- 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]
|
||||
139
.playwright-mcp/page-2026-06-16T01-59-42-191Z.yml
Normal file
139
.playwright-mcp/page-2026-06-16T01-59-42-191Z.yml
Normal file
@ -0,0 +1,139 @@
|
||||
- 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]: 발행 패키지 없음 — 재가공에서 생성
|
||||
139
.playwright-mcp/page-2026-06-16T01-59-56-230Z.yml
Normal file
139
.playwright-mcp/page-2026-06-16T01-59-56-230Z.yml
Normal file
@ -0,0 +1,139 @@
|
||||
- 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]: 발행 패키지 없음 — 재가공에서 생성
|
||||
170
.playwright-mcp/page-2026-06-16T02-00-34-942Z.yml
Normal file
170
.playwright-mcp/page-2026-06-16T02-00-34-942Z.yml
Normal file
@ -0,0 +1,170 @@
|
||||
- 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%
|
||||
148
.playwright-mcp/page-2026-06-16T02-04-09-903Z.yml
Normal file
148
.playwright-mcp/page-2026-06-16T02-04-09-903Z.yml
Normal file
@ -0,0 +1,148 @@
|
||||
- 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%
|
||||
148
.playwright-mcp/page-2026-06-16T02-04-18-776Z.yml
Normal file
148
.playwright-mcp/page-2026-06-16T02-04-18-776Z.yml
Normal file
@ -0,0 +1,148 @@
|
||||
- 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%
|
||||
110
.playwright-mcp/page-2026-06-16T09-13-45-525Z.yml
Normal file
110
.playwright-mcp/page-2026-06-16T09-13-45-525Z.yml
Normal file
@ -0,0 +1,110 @@
|
||||
- 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]
|
||||
110
.playwright-mcp/page-2026-06-16T09-14-11-775Z.yml
Normal file
110
.playwright-mcp/page-2026-06-16T09-14-11-775Z.yml
Normal file
@ -0,0 +1,110 @@
|
||||
- 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]
|
||||
23261
.playwright-mcp/page-2026-06-16T09-14-23-598Z.yml
Normal file
23261
.playwright-mcp/page-2026-06-16T09-14-23-598Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
61
.playwright-mcp/page-2026-06-16T09-14-48-505Z.yml
Normal file
61
.playwright-mcp/page-2026-06-16T09-14-48-505Z.yml
Normal file
@ -0,0 +1,61 @@
|
||||
- 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: 새로고침
|
||||
8252
.playwright-mcp/page-2026-06-16T09-14-57-129Z.yml
Normal file
8252
.playwright-mcp/page-2026-06-16T09-14-57-129Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
89
.playwright-mcp/page-2026-06-16T21-44-35-508Z.yml
Normal file
89
.playwright-mcp/page-2026-06-16T21-44-35-508Z.yml
Normal file
@ -0,0 +1,89 @@
|
||||
- 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
|
||||
179
.playwright-mcp/page-2026-06-16T21-44-49-563Z.yml
Normal file
179
.playwright-mcp/page-2026-06-16T21-44-49-563Z.yml
Normal file
@ -0,0 +1,179 @@
|
||||
- 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]: 발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.
|
||||
86
.playwright-mcp/page-2026-06-16T21-47-49-363Z.yml
Normal file
86
.playwright-mcp/page-2026-06-16T21-47-49-363Z.yml
Normal file
@ -0,0 +1,86 @@
|
||||
- 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
|
||||
176
.playwright-mcp/page-2026-06-16T21-48-01-116Z.yml
Normal file
176
.playwright-mcp/page-2026-06-16T21-48-01-116Z.yml
Normal file
@ -0,0 +1,176 @@
|
||||
- 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]: 발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.
|
||||
92
.playwright-mcp/page-2026-06-16T21-48-22-464Z.yml
Normal file
92
.playwright-mcp/page-2026-06-16T21-48-22-464Z.yml
Normal file
@ -0,0 +1,92 @@
|
||||
- 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]
|
||||
24099
.playwright-mcp/page-2026-06-16T21-48-34-715Z.yml
Normal file
24099
.playwright-mcp/page-2026-06-16T21-48-34-715Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
92
.playwright-mcp/page-2026-06-16T21-51-13-954Z.yml
Normal file
92
.playwright-mcp/page-2026-06-16T21-51-13-954Z.yml
Normal file
@ -0,0 +1,92 @@
|
||||
- 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]
|
||||
24099
.playwright-mcp/page-2026-06-16T21-51-24-984Z.yml
Normal file
24099
.playwright-mcp/page-2026-06-16T21-51-24-984Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
90
.playwright-mcp/page-2026-06-16T21-52-45-985Z.yml
Normal file
90
.playwright-mcp/page-2026-06-16T21-52-45-985Z.yml
Normal file
@ -0,0 +1,90 @@
|
||||
- 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]
|
||||
23411
.playwright-mcp/page-2026-06-16T21-52-57-521Z.yml
Normal file
23411
.playwright-mcp/page-2026-06-16T21-52-57-521Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
92
.playwright-mcp/page-2026-06-16T21-55-35-434Z.yml
Normal file
92
.playwright-mcp/page-2026-06-16T21-55-35-434Z.yml
Normal file
@ -0,0 +1,92 @@
|
||||
- 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]
|
||||
23413
.playwright-mcp/page-2026-06-16T21-55-51-735Z.yml
Normal file
23413
.playwright-mcp/page-2026-06-16T21-55-51-735Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
92
.playwright-mcp/page-2026-06-16T22-25-52-454Z.yml
Normal file
92
.playwright-mcp/page-2026-06-16T22-25-52-454Z.yml
Normal file
@ -0,0 +1,92 @@
|
||||
- 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]
|
||||
BIN
dashboard.png
Normal file
BIN
dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
@ -0,0 +1,686 @@
|
||||
# 추천 채널 발굴 (떡상 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
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 없음.
|
||||
@ -0,0 +1,133 @@
|
||||
# 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(설정값 고정)
|
||||
118
docs/superpowers/specs/2026-06-25-gemini-url-subtitles-design.md
Normal file
118
docs/superpowers/specs/2026-06-25-gemini-url-subtitles-design.md
Normal file
@ -0,0 +1,118 @@
|
||||
# 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 호출은 라이브 검증(짧은 공개 쇼츠: 음성/화면 각각 한국어·타임코드 확인).
|
||||
@ -0,0 +1,125 @@
|
||||
# 추천 채널 발굴 (떡상 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)
|
||||
|
||||
- 구독자 증가율 추적(스냅샷 누적 필요)
|
||||
- 키워드 기반 검색(이번엔 지역 광범위)
|
||||
- 자동 등록(수동 검토 유지)
|
||||
- 추천 사유 다중 지표(이번엔 배율 단일 기준)
|
||||
@ -43,6 +43,9 @@ public class Channel {
|
||||
@Column(name = "uploads_playlist_id")
|
||||
private String uploadsPlaylistId;
|
||||
|
||||
@Column(length = 8)
|
||||
private String country; // YouTube snippet.country (ISO-3166 alpha-2, 예: KR/JP/US). 없으면 null
|
||||
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@CreatedDate
|
||||
@ -78,5 +81,9 @@ public class Channel {
|
||||
this.uploadsPlaylistId = uploadsPlaylistId;
|
||||
}
|
||||
|
||||
public void setCountry(String country) {
|
||||
this.country = country;
|
||||
}
|
||||
|
||||
// Domain Logic methods here if needed
|
||||
}
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@ -20,6 +21,7 @@ import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@ -40,6 +42,7 @@ public class ChannelService {
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
||||
private final ChannelSnapshotRepository channelSnapshotRepository;
|
||||
private final RecommendedChannelRepository recommendedChannelRepository;
|
||||
|
||||
@Value("${youtube.api.key}") // application.yml(youtube.api.key) → 환경변수 YOUTUBE_API_KEY 오버라이드
|
||||
private String youtubeApiKey;
|
||||
@ -87,6 +90,7 @@ public class ChannelService {
|
||||
Long videoCount = Long.parseLong(statistics.get("videoCount").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)
|
||||
.map(existingChannel -> {
|
||||
@ -133,6 +137,9 @@ public class ChannelService {
|
||||
// But wait, I can't break the build.
|
||||
// Let's implement the rest of the service methods.
|
||||
|
||||
if (country != null && !country.isBlank()) {
|
||||
channel.setCountry(country.toUpperCase());
|
||||
}
|
||||
Channel saved = channelRepository.save(channel);
|
||||
captureSnapshot(saved); // 성장 추이용 일별 스냅샷 기록(upsert)
|
||||
return saved;
|
||||
@ -143,6 +150,27 @@ 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. */
|
||||
private void captureSnapshot(Channel channel) {
|
||||
java.time.LocalDate today = java.time.LocalDate.now();
|
||||
@ -393,19 +421,34 @@ public class ChannelService {
|
||||
*/
|
||||
@Transactional
|
||||
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)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||
|
||||
String apiUrl = pythonBaseUrl + "/transcribe";
|
||||
log.info("Requesting whisper transcription for video {} ({} bytes, lang={})",
|
||||
channelVideoId, file.getSize(), language);
|
||||
channelVideoId, sizeBytes, language);
|
||||
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", toFileResource(file));
|
||||
body.add("file", resource);
|
||||
if (language != null && !language.isBlank()) {
|
||||
body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja 등)
|
||||
}
|
||||
@ -418,22 +461,7 @@ public class ChannelService {
|
||||
}
|
||||
|
||||
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||
|
||||
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
|
||||
ChannelVideoScript script = new ChannelVideoScript();
|
||||
script.setChannelVideoId(channelVideoId);
|
||||
script.setVideoId(video.getVideoId());
|
||||
script.setLanguage(dto.getLanguage());
|
||||
script.setTranscript(dto.getTranscript());
|
||||
script.setSegmentsJson(objectMapper.writeValueAsString(
|
||||
dto.getSegments() == null ? Collections.emptyList() : dto.getSegments()));
|
||||
channelVideoScriptRepository.save(script);
|
||||
|
||||
video.setHasScript(true);
|
||||
channelVideoRepository.save(video);
|
||||
persistScript(video, channelVideoId, dto);
|
||||
|
||||
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
|
||||
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
|
||||
@ -444,6 +472,26 @@ public class ChannelService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 전사 결과 저장: 기존 스크립트 제거 후 새 스크립트 저장 + hasScript 승격. */
|
||||
private void persistScript(ChannelVideo video, Long channelVideoId, ScriptResponseDto dto)
|
||||
throws com.fasterxml.jackson.core.JsonProcessingException {
|
||||
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
|
||||
ChannelVideoScript script = new ChannelVideoScript();
|
||||
script.setChannelVideoId(channelVideoId);
|
||||
script.setVideoId(video.getVideoId());
|
||||
script.setLanguage(dto.getLanguage());
|
||||
script.setTranscript(dto.getTranscript());
|
||||
script.setSegmentsJson(objectMapper.writeValueAsString(
|
||||
dto.getSegments() == null ? Collections.emptyList() : dto.getSegments()));
|
||||
channelVideoScriptRepository.save(script);
|
||||
|
||||
video.setHasScript(true);
|
||||
channelVideoRepository.save(video);
|
||||
}
|
||||
|
||||
/** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */
|
||||
public List<ScriptSegment> getSegments(Long channelVideoId) {
|
||||
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||
@ -478,6 +526,20 @@ public class ChannelService {
|
||||
* keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다.
|
||||
*/
|
||||
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);
|
||||
if (segments.isEmpty()) {
|
||||
// 사용자에게 이유가 보이도록 400(IllegalArgumentException → GlobalExceptionHandler 가 메시지 노출).
|
||||
@ -491,7 +553,7 @@ public class ChannelService {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", toFileResource(file));
|
||||
body.add("file", resource);
|
||||
body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...]
|
||||
body.add("speed", String.valueOf(speed));
|
||||
|
||||
@ -507,6 +569,11 @@ public class ChannelService {
|
||||
}
|
||||
}
|
||||
|
||||
/** File(다운로드 캐시) → multipart 전송용 Resource(파일명 보존, 디스크 스트리밍). */
|
||||
private Resource toFileResource(File file) {
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
/** MultipartFile → multipart 전송용 Resource(파일명 보존). */
|
||||
private Resource toFileResource(MultipartFile file) throws java.io.IOException {
|
||||
final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank())
|
||||
|
||||
@ -133,6 +133,70 @@ public class ChannelVideoCurationController {
|
||||
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)
|
||||
@Operation(summary = "업로드 영상 전사(Whisper)",
|
||||
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||
@ -156,14 +220,17 @@ public class ChannelVideoCurationController {
|
||||
|
||||
@PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더",
|
||||
description = "업로드 영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
|
||||
+ "multipart: file, pad, minGap, speed. keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
|
||||
description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
|
||||
+ "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 "
|
||||
+ "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
|
||||
public ResponseEntity<byte[]> render(@PathVariable Long id,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "file", required = false) MultipartFile file,
|
||||
@RequestParam(defaultValue = "0.15") double pad,
|
||||
@RequestParam(defaultValue = "0.3") double minGap,
|
||||
@RequestParam(defaultValue = "1.0") double speed) {
|
||||
byte[] mp4 = curationService.renderTrimmed(id, file, pad, minGap, speed);
|
||||
byte[] mp4 = (file != null && !file.isEmpty())
|
||||
? curationService.renderTrimmed(id, file, pad, minGap, speed)
|
||||
: curationService.renderTrimmedFromCached(id, pad, minGap, speed);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(new MediaType("video", "mp4"));
|
||||
headers.setContentDisposition(
|
||||
|
||||
@ -24,6 +24,9 @@ public class ChannelVideoCurationService {
|
||||
private final ChannelVideoRepository channelVideoRepository;
|
||||
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
||||
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_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
|
||||
@ -121,6 +124,144 @@ public class ChannelVideoCurationService {
|
||||
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=원본). */
|
||||
public String buildSrt(Long videoId, double speed) {
|
||||
ChannelVideo v = find(videoId);
|
||||
@ -163,12 +304,13 @@ public class ChannelVideoCurationService {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 수집함에서 영상 제거(연결된 스크립트도 함께 삭제). */
|
||||
/** 수집함에서 영상 제거(연결된 스크립트 + 다운로드 캐시도 함께 삭제). */
|
||||
@Transactional
|
||||
public void delete(Long videoId) {
|
||||
ChannelVideo video = find(videoId);
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
videoDownloadService.deleteCache(video.getId()); // 고아 mp4 방지
|
||||
channelVideoRepository.delete(video);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -24,5 +24,9 @@ public class ScriptResponseDto {
|
||||
private double start;
|
||||
private double end;
|
||||
private String text;
|
||||
|
||||
/** 화면 자막(Gemini): 번역 전 원문. 음성 전사엔 없으므로 null 이면 직렬화 생략. */
|
||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||
private String original;
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,4 +34,15 @@ public class RestTemplateConfig {
|
||||
.readTimeout(Duration.ofMinutes(10))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini(유튜브 URL 영상 이해)용 RestTemplate. 영상 분석은 수십 초~수 분 걸릴 수 있어 read timeout 을 길게 둔다.
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate geminiRestTemplate(RestTemplateBuilder builder) {
|
||||
return builder
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.readTimeout(Duration.ofMinutes(5))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ public class ScheduledCollectionService {
|
||||
private final ChannelRepository channelRepository;
|
||||
private final ChannelService channelService;
|
||||
private final YoutubeQuotaGuard quotaGuard;
|
||||
private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService;
|
||||
|
||||
@Value("${hlab.scheduler.channel-collection.enabled:true}")
|
||||
private boolean enabled;
|
||||
@ -32,6 +33,20 @@ public class ScheduledCollectionService {
|
||||
@Value("${hlab.scheduler.channel-snapshot.enabled:true}")
|
||||
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, 대략치). */
|
||||
private static final long EST_UNITS_PER_CHANNEL = 12;
|
||||
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
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 응답에서 세그먼트 배열을 추출(순수). candidates→content→parts→text 안의 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/main/java/com/hlab/yanalyst/service/TranslateService.java
Normal file
126
src/main/java/com/hlab/yanalyst/service/TranslateService.java
Normal file
@ -0,0 +1,126 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -59,7 +59,9 @@ public class YoutubeSearchService {
|
||||
List<JsonNode> allItems = new ArrayList<>();
|
||||
|
||||
for (String target : searchTargets) {
|
||||
String order = StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date";
|
||||
String order = StringUtils.hasText(condition.getOrder())
|
||||
? condition.getOrder()
|
||||
: (StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date");
|
||||
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(searchApiUrl)
|
||||
.queryParam("part", "snippet")
|
||||
|
||||
@ -34,4 +34,10 @@ public class ChannelApiController {
|
||||
channelService.deleteChannel(id);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-country")
|
||||
@Operation(summary = "Backfill country", description = "추천 채널의 발견 지역으로 기존 채널의 국가를 채운다")
|
||||
public ApiResponse<Integer> backfillCountry() {
|
||||
return ApiResponse.ok(channelService.backfillCountriesFromRecommend());
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,12 @@ public class WebController {
|
||||
return "discover";
|
||||
}
|
||||
|
||||
@GetMapping("/recommend")
|
||||
public String recommend(Model model) {
|
||||
model.addAttribute("currentPage", "recommend");
|
||||
return "recommend";
|
||||
}
|
||||
|
||||
@GetMapping("/publish")
|
||||
public String publish(Model model) {
|
||||
model.addAttribute("currentPage", "publish");
|
||||
|
||||
@ -11,4 +11,5 @@ public class YoutubeSearchCondition {
|
||||
private String format; // "SHORTS" or "LONG_FORM"
|
||||
private java.util.Map<String, String> pageTokens;
|
||||
private List<String> channelIds; // e.g. ["UC...", "UC..."]
|
||||
private String order; // search.list order 강제(예: "viewCount"). 비우면 키워드 유무로 자동.
|
||||
}
|
||||
|
||||
@ -54,6 +54,24 @@ youtube:
|
||||
python:
|
||||
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:
|
||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||
scheduler:
|
||||
@ -63,5 +81,15 @@ hlab:
|
||||
channel-snapshot:
|
||||
enabled: ${CHANNEL_SNAPSHOT_ENABLED:true}
|
||||
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:
|
||||
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}
|
||||
|
||||
BIN
src/main/resources/static/img/usage/dl-1-button.png
Normal file
BIN
src/main/resources/static/img/usage/dl-1-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
src/main/resources/static/img/usage/dl-2-segments.png
Normal file
BIN
src/main/resources/static/img/usage/dl-2-segments.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
BIN
src/main/resources/static/img/usage/dl-3-export.png
Normal file
BIN
src/main/resources/static/img/usage/dl-3-export.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@ -19,7 +19,7 @@
|
||||
전체 선택
|
||||
</label>
|
||||
<button id="viewSelectedBtn" class="btn btn-outline" onclick="viewSelectedVideos()" style="display: none;">
|
||||
<i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> View Selected Videos (<span id="selectedCount">0</span>)
|
||||
<i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> 선택 영상 보기 (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openAddModal()">
|
||||
<i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> 채널 추가
|
||||
@ -27,10 +27,20 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="card relative cursor-pointer hover:border-[var(--primary)] transition-all"
|
||||
<div class="card channel-card relative cursor-pointer hover:border-[var(--primary)] transition-all"
|
||||
th:each="channel : ${channels}"
|
||||
th:data-country="${channel.country}"
|
||||
th:onclick="'toggleCardSelection(this, event, ' + ${channel.id} + ')'"
|
||||
style="user-select: none;">
|
||||
<!-- Custom Checkbox -->
|
||||
@ -188,20 +198,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 탭에서 보이는(숨겨지지 않은) 카드의 체크박스만 반환
|
||||
function visibleCheckboxes() {
|
||||
return Array.from(document.querySelectorAll('.channel-checkbox'))
|
||||
.filter(cb => { const c = cb.closest('.card'); return c && c.style.display !== 'none'; });
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const checkboxes = document.querySelectorAll('.channel-checkbox');
|
||||
const checked = document.querySelectorAll('.channel-checkbox:checked');
|
||||
const count = checked.length;
|
||||
const visibleBoxes = visibleCheckboxes();
|
||||
const checkedVisible = visibleBoxes.filter(cb => cb.checked);
|
||||
const count = checkedVisible.length;
|
||||
|
||||
document.getElementById('selectedCount').innerText = count;
|
||||
document.getElementById('viewSelectedBtn').style.display = count > 0 ? 'flex' : 'none';
|
||||
|
||||
// Update select all checkbox state
|
||||
// 전체 선택 상태는 '보이는 탭' 기준
|
||||
const selectAll = document.getElementById('selectAllCheckbox');
|
||||
if (count === 0) {
|
||||
selectAll.checked = false;
|
||||
selectAll.indeterminate = false;
|
||||
} else if (count === checkboxes.length) {
|
||||
} else if (count === visibleBoxes.length) {
|
||||
selectAll.checked = true;
|
||||
selectAll.indeterminate = false;
|
||||
} else {
|
||||
@ -213,20 +230,21 @@
|
||||
checkboxes.forEach(cb => {
|
||||
const card = cb.closest('.card');
|
||||
const customCb = card.querySelector('.custom-checkbox');
|
||||
const checkIcon = customCb.querySelector('i');
|
||||
// lucide 가 <i data-lucide> 를 <svg> 로 치환하므로 둘 다 잡는다(없으면 무시).
|
||||
const checkIcon = customCb.querySelector('svg, i');
|
||||
|
||||
if (cb.checked) {
|
||||
card.style.borderColor = 'var(--primary)';
|
||||
card.style.background = 'rgba(59, 130, 246, 0.05)';
|
||||
customCb.style.background = 'var(--primary)';
|
||||
customCb.style.borderColor = 'var(--primary)';
|
||||
checkIcon.style.display = 'block';
|
||||
if (checkIcon) checkIcon.style.display = 'block';
|
||||
} else {
|
||||
card.style.borderColor = 'var(--glass-border)';
|
||||
card.style.background = 'var(--glass-bg)';
|
||||
customCb.style.background = 'var(--surface-2)';
|
||||
customCb.style.borderColor = 'var(--border-strong)';
|
||||
checkIcon.style.display = 'none';
|
||||
if (checkIcon) checkIcon.style.display = 'none';
|
||||
}
|
||||
});
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
@ -234,8 +252,8 @@
|
||||
|
||||
function toggleSelectAll() {
|
||||
const isChecked = document.getElementById('selectAllCheckbox').checked;
|
||||
const checkboxes = document.querySelectorAll('.channel-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = isChecked);
|
||||
// 현재 활성 탭에서 보이는 카드만 선택/해제
|
||||
visibleCheckboxes().forEach(cb => cb.checked = isChecked);
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
@ -257,6 +275,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 국가별 탭 필터 ----
|
||||
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
|
||||
window.onclick = function (event) {
|
||||
const modal = document.getElementById('addChannelModal');
|
||||
|
||||
@ -27,6 +27,9 @@
|
||||
<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>
|
||||
</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'">
|
||||
<i data-lucide="users" class="nav-icon"></i><span class="nav-text">채널</span>
|
||||
</a>
|
||||
|
||||
@ -3,86 +3,84 @@
|
||||
layout:decorate="~{layout/base}">
|
||||
|
||||
<head>
|
||||
<title>h-lab - Multi-Channel Videos</title>
|
||||
<title>h-lab - 채널 영상 모아보기</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<header class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<a th:href="@{/channels}" class="text-muted hover:text-white transition-colors">
|
||||
<i data-lucide="arrow-left" style="width: 20px;"></i>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">Search within Selected Channels</h1>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a th:href="@{/channels}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a>
|
||||
<h1>채널 영상 모아보기</h1>
|
||||
</div>
|
||||
<p class="sub">선택한 <span th:text="${channels != null ? #lists.size(channels) : 0}">0</span>개 채널의 영상을 한 번에 검색·정렬하고 수집함에 담습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6 mb-6">
|
||||
<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-4" th:if="${channels != null}">
|
||||
<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="Thumbnail"
|
||||
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 class="card mb-4" th:if="${channels != null}">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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);">
|
||||
<img th:src="${channel.thumbnailUrl}" alt="" th:if="${channel.thumbnailUrl != null}" style="width:24px; height:24px; border-radius:50%; object-fit:cover;">
|
||||
<span class="text-sm" th:text="${channel.title}">채널</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Search / Filter Area (Copied from videos.html) -->
|
||||
<div class="card mb-6">
|
||||
<!-- 검색 조건 -->
|
||||
<div class="card mb-4">
|
||||
<form id="searchForm" onsubmit="event.preventDefault(); executeSearch();">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end;">
|
||||
<!-- Period -->
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
|
||||
<label class="text-sm font-bold text-muted">Period</label>
|
||||
<select id="periodDays" class="w-full"
|
||||
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="1" style="background: var(--surface); color: var(--text);">Within 1 Day</option>
|
||||
<option value="7" style="background: var(--surface); color: var(--text);" selected>Within 7 Days</option>
|
||||
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</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>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end;">
|
||||
<div style="display:flex; flex-direction:column; gap:6px; min-width:150px;">
|
||||
<label class="text-sm text-muted">기간</label>
|
||||
<select id="periodDays" class="pub-in" style="height:42px;">
|
||||
<option value="1">최근 1일</option>
|
||||
<option value="7" selected>최근 7일</option>
|
||||
<option value="10">최근 10일</option>
|
||||
<option value="15">최근 15일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
<option value="0">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Format -->
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
|
||||
<label class="text-sm font-bold text-muted">Format</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 style="display:flex; flex-direction:column; gap:6px; min-width:160px;">
|
||||
<label class="text-sm text-muted">형식</label>
|
||||
<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);">
|
||||
<label class="flex items-center gap-1 cursor-pointer"><input type="radio" name="format" value="SHORTS" checked> Shorts</label>
|
||||
<label class="flex items-center gap-1 cursor-pointer"><input type="radio" name="format" value="LONG_FORM"> 롱폼</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Size -->
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;">
|
||||
<label class="text-sm font-bold text-muted">Load Size</label>
|
||||
<select id="pageSize" class="w-full"
|
||||
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>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; min-width:130px;">
|
||||
<label class="text-sm text-muted">불러올 수</label>
|
||||
<select id="pageSize" class="pub-in" style="height:42px;">
|
||||
<option value="20">20개</option>
|
||||
<option value="50" selected>50개</option>
|
||||
<option value="100">100개</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 type="submit" class="btn btn-primary px-6 py-2 flex items-center gap-1" id="searchBtn" style="height:42px;">
|
||||
<i data-lucide="search" style="width:16px;"></i> 검색
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 수집 툴바 -->
|
||||
<div class="flex items-center justify-between mb-3" style="gap:12px;">
|
||||
<span id="selInfo" class="text-sm text-muted"></span>
|
||||
<!-- 정렬 + 담기 툴바 -->
|
||||
<div class="flex items-center justify-between mb-3" style="gap:12px; flex-wrap:wrap;">
|
||||
<div class="flex items-center gap-2">
|
||||
<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">
|
||||
<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">
|
||||
<i data-lucide="check-square" style="width:16px;"></i> 선택 담기
|
||||
</button>
|
||||
@ -92,358 +90,216 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video List Table -->
|
||||
<div class="card p-0" style="overflow-x: auto;">
|
||||
<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 id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:1rem;">
|
||||
<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">검색 조건을 정하고 ‘검색’을 누르세요.</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-center">
|
||||
<button id="loadMoreBtn" class="btn btn-secondary w-full p-3" style="display: none;" onclick="handleLoadMore()">Load More</button>
|
||||
<button id="loadMoreBtn" class="btn btn-secondary px-6 py-3" style="display:none;" onclick="handleLoadMore()">더 보기</button>
|
||||
</div>
|
||||
|
||||
<!-- Video Player Modal -->
|
||||
<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: 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;">
|
||||
<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;">×</button>
|
||||
<!-- 미리보기 모달 -->
|
||||
<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">영상</h3>
|
||||
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<div id="iframeContainer" style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; background: #000;"></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>
|
||||
.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">
|
||||
/*<![CDATA[*/
|
||||
const selectedYoutubeChannelIds = /*[[${selectedYoutubeIds}]]*/ [];
|
||||
/*]]>*/
|
||||
|
||||
let allLoadedVideos = [];
|
||||
let currentTokens = {};
|
||||
let displayedCount = 0;
|
||||
let currentCondition = {};
|
||||
let currentSort = 'performance';
|
||||
|
||||
let currentSortColumn = 'performance';
|
||||
let currentSortOrder = 'desc';
|
||||
|
||||
function handleSort(column) {
|
||||
if (currentSortColumn === column) {
|
||||
currentSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSortColumn = column;
|
||||
currentSortOrder = 'desc';
|
||||
}
|
||||
updateSortIcons();
|
||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmt(n){ return (n==null)?'0':Number(n).toLocaleString(); }
|
||||
|
||||
function onSortChange(){
|
||||
currentSort = document.getElementById('sortSel').value;
|
||||
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
||||
const targetLimit = displayedCount > 0 ? displayedCount : size;
|
||||
renderTable(targetLimit);
|
||||
renderGrid(displayedCount > 0 ? displayedCount : size);
|
||||
}
|
||||
|
||||
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
|
||||
function executeSearch(){
|
||||
const periodDays = parseInt(document.getElementById('periodDays').value);
|
||||
const formatRadios = document.querySelectorAll('input[name="format"]:checked');
|
||||
const format = formatRadios.length > 0 ? formatRadios[0].value : 'SHORTS';
|
||||
|
||||
currentCondition = {
|
||||
keyword: keyword,
|
||||
channelIds: selectedYoutubeChannelIds, // Use selected channels
|
||||
periodDays: periodDays,
|
||||
format: format,
|
||||
pageTokens: {}
|
||||
};
|
||||
|
||||
allLoadedVideos = [];
|
||||
currentTokens = {};
|
||||
displayedCount = 0;
|
||||
|
||||
document.getElementById('resultBody').innerHTML = '';
|
||||
currentCondition = { keyword:'', channelIds:selectedYoutubeChannelIds, periodDays:periodDays, format:format, pageTokens:{} };
|
||||
allLoadedVideos = []; currentTokens = {}; displayedCount = 0;
|
||||
document.getElementById('loadMoreBtn').style.display = 'none';
|
||||
|
||||
fetchMore(true);
|
||||
}
|
||||
|
||||
async function fetchMore(isFirst) {
|
||||
async function fetchMore(isFirst){
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||
const resultBody = document.getElementById('resultBody');
|
||||
|
||||
if (isFirst) {
|
||||
searchBtn.disabled = true;
|
||||
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...';
|
||||
}
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
if (isFirst){
|
||||
searchBtn.disabled = true; searchBtn.innerHTML = '<span class="text-sm">검색 중…</span>';
|
||||
grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1; padding:2rem; text-align:center;">'+selectedYoutubeChannelIds.length+'개 채널에서 불러오는 중… 잠시 걸립니다.</div>';
|
||||
} else { loadMoreBtn.disabled = true; loadMoreBtn.innerHTML = '불러오는 중…'; }
|
||||
currentCondition.pageTokens = currentTokens;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/youtube/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentCondition)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('API request failed');
|
||||
|
||||
const response = await fetch('/api/youtube/search', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(currentCondition) });
|
||||
if (!response.ok) throw new Error('검색 API 실패');
|
||||
const pageDto = await response.json();
|
||||
|
||||
if (isFirst) {
|
||||
resultBody.innerHTML = '';
|
||||
}
|
||||
|
||||
allLoadedVideos = allLoadedVideos.concat(pageDto.items || []);
|
||||
currentTokens = pageDto.nextTokens || {};
|
||||
|
||||
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
||||
const targetLimit = isFirst ? size : displayedCount + size;
|
||||
renderTable(targetLimit);
|
||||
} catch (error) {
|
||||
renderGrid(isFirst ? size : displayedCount + size);
|
||||
} catch(error){
|
||||
console.error('Search error:', error);
|
||||
if (isFirst) {
|
||||
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-danger">Error occurred during search.</td></tr>';
|
||||
}
|
||||
if (isFirst) grid.innerHTML = '<div style="grid-column:1/-1; padding:2rem; text-align:center; color:#f87171;">검색 중 오류가 발생했습니다.</div>';
|
||||
} finally {
|
||||
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();
|
||||
}
|
||||
if (isFirst){ searchBtn.disabled = false; searchBtn.innerHTML = '<i data-lucide="search" style="width:16px;"></i> 검색'; if(window.lucide) lucide.createIcons(); }
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
function handleLoadMore(){
|
||||
const size = parseInt(document.getElementById('pageSize').value) || 50;
|
||||
if (displayedCount < allLoadedVideos.length) {
|
||||
renderTable(displayedCount + size);
|
||||
} else {
|
||||
fetchMore(false);
|
||||
}
|
||||
if (displayedCount < allLoadedVideos.length) renderGrid(displayedCount + size);
|
||||
else fetchMore(false);
|
||||
}
|
||||
|
||||
function renderTable(limit) {
|
||||
const resultBody = document.getElementById('resultBody');
|
||||
function ratioOf(v){
|
||||
if (v.viewCount != null && v.subscriberCount != null && v.subscriberCount > 0) return v.viewCount / v.subscriberCount;
|
||||
return (v.viewCount > 0) ? Infinity : -1;
|
||||
}
|
||||
|
||||
if (allLoadedVideos.length === 0) {
|
||||
resultBody.innerHTML = '<tr><td colspan="9" class="p-8 text-center text-muted">No results found within selected channels.</td></tr>';
|
||||
function renderGrid(limit){
|
||||
const grid = document.getElementById('grid');
|
||||
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';
|
||||
return;
|
||||
}
|
||||
|
||||
allLoadedVideos.sort((a, b) => {
|
||||
let valA = 0, valB = 0;
|
||||
if (currentSortColumn === 'views') {
|
||||
valA = a.viewCount || 0;
|
||||
valB = b.viewCount || 0;
|
||||
} 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;
|
||||
allLoadedVideos.sort((a,b)=>{
|
||||
let va=0, vb=0;
|
||||
if (currentSort==='views'){ va=a.viewCount||0; vb=b.viewCount||0; }
|
||||
else if (currentSort==='subs'){ va=a.subscriberCount||0; vb=b.subscriberCount||0; }
|
||||
else if (currentSort==='date'){ va=new Date(a.publishedAt).getTime(); vb=new Date(b.publishedAt).getTime(); }
|
||||
else { va=ratioOf(a); vb=ratioOf(b); }
|
||||
return vb - va; // 내림차순
|
||||
});
|
||||
|
||||
resultBody.innerHTML = '';
|
||||
const actualLimit = Math.min(limit, allLoadedVideos.length);
|
||||
const videosToRender = allLoadedVideos.slice(0, actualLimit);
|
||||
|
||||
videosToRender.forEach(video => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid var(--glass-border)';
|
||||
|
||||
const pubDate = new Date(video.publishedAt);
|
||||
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 viewStr = video.viewCount != null ? video.viewCount.toLocaleString() : '0';
|
||||
const subStr = video.subscriberCount != null ? video.subscriberCount.toLocaleString() : '0';
|
||||
|
||||
let perfStr = '-';
|
||||
let perfStyle = 'color: var(--text-muted);';
|
||||
if (video.viewCount != null && video.subscriberCount != null && video.subscriberCount > 0) {
|
||||
const ratio = (video.viewCount / video.subscriberCount) * 100;
|
||||
perfStr = ratio.toFixed(1) + '%';
|
||||
if (ratio >= 100) perfStyle = 'color: #ef4444; font-weight: bold;';
|
||||
else if (ratio >= 30) perfStyle = 'color: #f59e0b; font-weight: bold;';
|
||||
else perfStyle = 'color: #10b981; font-weight: bold;';
|
||||
}
|
||||
|
||||
const safeTitle = video.title.replace(/"/g, """);
|
||||
|
||||
tr.innerHTML =
|
||||
'<td class="p-4">' +
|
||||
'<input type="checkbox" class="rowChk" value="' + video.videoId + '" onchange="updateSelInfo()">' +
|
||||
'</td>' +
|
||||
'<td class="p-4">' +
|
||||
'<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);
|
||||
});
|
||||
const list = allLoadedVideos.slice(0, actualLimit);
|
||||
grid.innerHTML = list.map(v=>{
|
||||
const d = new Date(v.publishedAt);
|
||||
const dateStr = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
|
||||
const r = ratioOf(v);
|
||||
let ratioStr='-', ratioBg='#9ca3af';
|
||||
if (r >= 0 && r !== Infinity){ ratioStr=r.toFixed(1)+'x'; ratioBg = r>=10 ? '#ef4444' : (r>=2 ? '#f59e0b' : '#10b981'); }
|
||||
else if (r === Infinity){ ratioStr='∞'; ratioBg='#ef4444'; }
|
||||
const safeTitle = esc(v.title).replace(/"/g,'"');
|
||||
return '<div class="vc-card" id="vc-'+v.videoId+'">'
|
||||
+ '<div class="vc-thumb-wrap">'
|
||||
+ '<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)">'
|
||||
+ '<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>'
|
||||
+ '</div>'
|
||||
+ '<div class="vc-body">'
|
||||
+ '<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>'
|
||||
+ '<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>'
|
||||
+ '<div class="mt-2 flex items-center gap-2" style="flex-wrap:wrap;">'
|
||||
+ '<span class="vc-ratio" style="background:'+ratioBg+';">🔥 '+ratioStr+'</span>'
|
||||
+ '<span class="text-sm text-muted">👁 '+fmt(v.viewCount)+'</span>'
|
||||
+ '<span class="text-sm text-muted">👤 '+fmt(v.subscriberCount)+'</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="text-xs text-muted mt-1">'+dateStr+'</div>'
|
||||
+ '<div class="vc-actions">'
|
||||
+ '<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>'
|
||||
+ '<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>'
|
||||
+ '</div>'
|
||||
+ '</div></div>';
|
||||
}).join('');
|
||||
|
||||
displayedCount = actualLimit;
|
||||
updateLoadMoreButton();
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
if (selectAll) selectAll.checked = false;
|
||||
const selectAll = document.getElementById('selectAll'); if (selectAll) selectAll.checked = false;
|
||||
updateSelInfo();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function downloadVideo(videoUrl) {
|
||||
// cobalt.tools is a clean, ad-free high-quality downloader
|
||||
// Since it doesn't support direct URL params for downloading in the same way,
|
||||
// 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 onRowCheck(cb, vid){
|
||||
const card = document.getElementById('vc-'+vid);
|
||||
if (card) card.classList.toggle('sel', cb.checked);
|
||||
updateSelInfo();
|
||||
}
|
||||
|
||||
function updateLoadMoreButton() {
|
||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||
if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0) {
|
||||
loadMoreBtn.style.display = 'block';
|
||||
loadMoreBtn.disabled = false;
|
||||
loadMoreBtn.innerHTML = 'Load More (Next 50)';
|
||||
} else {
|
||||
loadMoreBtn.style.display = 'none';
|
||||
}
|
||||
function updateLoadMoreButton(){
|
||||
const b = document.getElementById('loadMoreBtn');
|
||||
if (displayedCount < allLoadedVideos.length || Object.keys(currentTokens).length > 0){ b.style.display='block'; b.disabled=false; b.innerHTML='더 보기'; }
|
||||
else b.style.display='none';
|
||||
}
|
||||
|
||||
function openVideoModal(videoId, title) {
|
||||
const modal = document.getElementById('videoModal');
|
||||
const container = document.getElementById('iframeContainer');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
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 downloadVideo(videoUrl){ window.open(videoUrl.replace('youtube.com','ssyoutube.com'), '_blank'); }
|
||||
function openVideoModal(videoId, title){
|
||||
document.getElementById('modalTitle').textContent = title || '';
|
||||
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>';
|
||||
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(); });
|
||||
function copyToClipboard(text){ navigator.clipboard.writeText(text).then(()=>{ alert('URL이 클립보드에 복사되었습니다.'); }); }
|
||||
|
||||
// ---- 수집함에 담기 ----
|
||||
function toggleSelectAll(checked) {
|
||||
document.querySelectorAll('.rowChk').forEach(cb => cb.checked = 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); });
|
||||
updateSelInfo();
|
||||
}
|
||||
function updateSelInfo() {
|
||||
const checked = document.querySelectorAll('.rowChk:checked').length;
|
||||
const info = document.getElementById('selInfo');
|
||||
if (info) info.textContent = checked > 0 ? (checked + '개 선택됨') : '';
|
||||
function updateSelInfo(){
|
||||
const n = document.querySelectorAll('.rowChk:checked').length;
|
||||
const info = document.getElementById('selInfo'); if (info) info.textContent = n>0 ? (n+'개 선택됨') : '';
|
||||
}
|
||||
async function postCollect(items, btn) {
|
||||
if (!items || items.length === 0) { alert('담을 영상이 없습니다.'); return; }
|
||||
async function postCollect(items, btn){
|
||||
if (!items || items.length === 0){ alert('담을 영상이 없습니다.'); return; }
|
||||
const original = btn ? btn.innerHTML : null;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '담는 중...'; }
|
||||
if (btn){ btn.disabled=true; btn.innerHTML='담는 중...'; }
|
||||
try {
|
||||
const res = await fetch('/api/youtube/collect', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items)
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || (json && json.success === false)) throw new Error((json && json.message) || ('HTTP ' + res.status));
|
||||
const res = await fetch('/api/youtube/collect', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(items) });
|
||||
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 || {};
|
||||
alert('수집함에 담기 완료\\n저장 ' + (d.saved || 0) + '건, 중복/스킵 ' + (d.skipped || 0) + '건');
|
||||
} catch (e) {
|
||||
alert('담기 실패: ' + e.message);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = original; if (window.lucide) window.lucide.createIcons(); }
|
||||
}
|
||||
alert('수집함에 담기 완료\n저장 '+(d.saved||0)+'건, 중복/스킵 '+(d.skipped||0)+'건');
|
||||
} catch(e){ alert('담기 실패: '+e.message); }
|
||||
finally { if (btn){ btn.disabled=false; btn.innerHTML=original; if(window.lucide) lucide.createIcons(); } }
|
||||
}
|
||||
function collectSelected() {
|
||||
const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb => cb.value);
|
||||
if (ids.length === 0) { alert('선택된 영상이 없습니다. 체크박스로 선택하세요.'); return; }
|
||||
function collectSelected(){
|
||||
const ids = Array.from(document.querySelectorAll('.rowChk:checked')).map(cb=>cb.value);
|
||||
if (ids.length === 0){ alert('선택된 영상이 없습니다. 체크박스로 선택하세요.'); return; }
|
||||
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() {
|
||||
if (allLoadedVideos.length === 0) { alert('먼저 검색하세요.'); return; }
|
||||
if (!confirm('현재 로드된 ' + allLoadedVideos.length + '개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return;
|
||||
function collectAll(){
|
||||
if (allLoadedVideos.length === 0){ alert('먼저 검색하세요.'); return; }
|
||||
if (!confirm('현재 로드된 '+allLoadedVideos.length+'개 영상을 모두 수집함에 담을까요? (이미 담긴 것은 자동 제외)')) return;
|
||||
postCollect(allLoadedVideos, document.getElementById('collectAllBtn'));
|
||||
}
|
||||
/*]]>*/
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
132
src/main/resources/templates/recommend.html
Normal file
132
src/main/resources/templates/recommend.html
Normal file
@ -0,0 +1,132 @@
|
||||
<!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;">×</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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
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,'"')+'" 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>
|
||||
@ -26,9 +26,50 @@
|
||||
<div class="modal-card">
|
||||
<div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">×</button></div>
|
||||
<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="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 선택). ‘URL자막’은 YouTube 자막을 평문으로(시간 없음) 가져옵니다.</div></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="help-item">
|
||||
<div class="hi-ic"><i data-lucide="list-video"></i></div>
|
||||
@ -50,9 +91,29 @@
|
||||
</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 class="card">
|
||||
<!-- 좌: 영상 + 정보 (스크롤해도 상단에 따라다님) -->
|
||||
<div class="card" style="position:sticky; top:1rem; align-self:start;">
|
||||
<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
|
||||
style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video>
|
||||
@ -71,23 +132,29 @@
|
||||
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
|
||||
<h3 class="text-lg font-bold">원본 스크립트</h3>
|
||||
<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="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
<label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;">
|
||||
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
|
||||
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" id="downloadBtn" onclick="downloadOriginal()" title="저장된 URL로 원본을 yt-dlp로 받아 자동 자막 추출(수동 다운로드+업로드 불필요)">
|
||||
<i data-lucide="download" 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)">
|
||||
</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()">
|
||||
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||||
</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>
|
||||
|
||||
@ -97,7 +164,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>
|
||||
|
||||
<!-- 평문 폴백 (URL 자막 추출 시) -->
|
||||
<textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, ‘URL자막’으로 YouTube 자막을 가져오세요."
|
||||
<textarea id="transcript" readonly placeholder="‘원본 다운로드’ 또는 ‘영상 업로드·자막 추출’로 영상에서 시간 싱크된 자막을 추출하세요."
|
||||
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/영상) -->
|
||||
@ -128,6 +195,9 @@
|
||||
<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 내보내기
|
||||
</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()">
|
||||
<i data-lucide="scissors" style="width:15px;"></i> 영상 내보내기
|
||||
</button>
|
||||
@ -145,6 +215,44 @@
|
||||
.seg-text { color:var(--text-2); }
|
||||
</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="flex items-center justify-between mb-3">
|
||||
@ -234,8 +342,11 @@
|
||||
}
|
||||
|
||||
// ===== 세그먼트(영상 싱크) =====
|
||||
let SEGMENTS = []; // 전사 원본 세그먼트(원본 영상 재생에 싱크)
|
||||
let SEGMENTS = []; // 자막 추출 원본 세그먼트(원본 영상 재생에 싱크)
|
||||
let UPLOADED_FILE = null; // 업로드한 영상 File (렌더 재전송용)
|
||||
let HAS_SERVER_FILE = false; // '원본 다운로드'로 서버에 캐시된 영상 보유 여부(렌더가 업로드 없이 재사용)
|
||||
let AI_AUDIO = []; // AI 자막 - 음성(Whisper+한국어) 세그먼트
|
||||
let AI_SCREEN = []; // AI 자막 - 화면(Gemini 한국어) 세그먼트
|
||||
let trimApplied = false; // 말 없는 구간 제거 미리보기 적용 여부
|
||||
let REMAPPED = []; // 무음 제거 후(배속 전) 세그먼트
|
||||
|
||||
@ -253,6 +364,22 @@
|
||||
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){
|
||||
SEGMENTS = Array.isArray(segs) ? segs : [];
|
||||
trimApplied = false; REMAPPED = [];
|
||||
@ -287,7 +414,15 @@
|
||||
rows.forEach(r=>{
|
||||
const on = Number(r.dataset.i)===activeIdx;
|
||||
r.classList.toggle('active', on);
|
||||
if(on) r.scrollIntoView({block:'nearest'});
|
||||
if(on){
|
||||
// 페이지 전체가 아니라 세그먼트 박스 안에서만 스크롤(영상 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -317,6 +452,87 @@
|
||||
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(){
|
||||
if(document.getElementById('trimOn').checked){ previewTrim(); }
|
||||
@ -349,7 +565,7 @@
|
||||
}
|
||||
|
||||
async function renderVideo(){
|
||||
if(!UPLOADED_FILE){ alert('먼저 영상을 업로드·전사하세요. (렌더에는 원본 영상 파일이 필요합니다)'); return; }
|
||||
if(!UPLOADED_FILE && !HAS_SERVER_FILE){ alert('먼저 "원본 다운로드" 또는 "영상 업로드·자막 추출"를 실행하세요. (렌더에는 원본 영상이 필요합니다)'); return; }
|
||||
const pad = parseFloat(document.getElementById('trimPad').value) || 0;
|
||||
const gap = parseFloat(document.getElementById('trimGap').value) || 0;
|
||||
const speed = curSpeed();
|
||||
@ -360,7 +576,7 @@
|
||||
status.textContent = '영상 렌더링 중… (자르기+인코딩, 잠시 걸립니다)';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', UPLOADED_FILE);
|
||||
if(UPLOADED_FILE) fd.append('file', UPLOADED_FILE); // 없으면 서버 캐시(원본 다운로드본) 사용
|
||||
fd.append('pad', pad); fd.append('minGap', gap); fd.append('speed', speed);
|
||||
const res = await fetch(API + '/' + VIDEO_ID + '/render', { method:'POST', body: fd });
|
||||
if(!res.ok){
|
||||
@ -382,10 +598,123 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 저장된 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){
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if(!file) return;
|
||||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
|
||||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관(업로드본이 서버 캐시보다 우선)
|
||||
|
||||
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
|
||||
const v = document.getElementById('localVideo');
|
||||
@ -393,11 +722,11 @@
|
||||
v.style.display = 'block';
|
||||
document.getElementById('player').style.display = 'none';
|
||||
|
||||
// 2) 서버로 보내 Whisper 전사
|
||||
// 2) 서버로 보내 Whisper 자막 추출
|
||||
const status = document.getElementById('transcribeStatus');
|
||||
const btn = document.getElementById('uploadBtn');
|
||||
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';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
@ -408,12 +737,12 @@
|
||||
renderSegments(s.segments);
|
||||
document.getElementById('transcript').value = s.transcript || '';
|
||||
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.duration ? (' · ' + mmss(s.duration)) : '');
|
||||
} catch(e){
|
||||
status.style.color = '#f87171';
|
||||
status.textContent = '전사 실패: ' + e.message;
|
||||
status.textContent = '자막 추출 실패: ' + e.message;
|
||||
} finally {
|
||||
btn.style.pointerEvents = ''; btn.style.opacity = '';
|
||||
if(window.lucide) lucide.createIcons();
|
||||
@ -443,6 +772,12 @@
|
||||
renderSegments(s.segments);
|
||||
} 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');
|
||||
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));
|
||||
@ -450,17 +785,6 @@
|
||||
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(){
|
||||
const t = document.getElementById('transcript').value;
|
||||
if(!t){ alert('복사할 스크립트가 없습니다.'); return; }
|
||||
@ -544,6 +868,9 @@
|
||||
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
|
||||
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); })();
|
||||
/*]]>*/
|
||||
</script>
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
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(); // 이미 없음
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
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("안녕");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user