Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
|
@@ -3,7 +3,7 @@ from googleapiclient.discovery import build
|
|
| 3 |
import os
|
| 4 |
import sqlite3
|
| 5 |
from datetime import datetime, timedelta
|
| 6 |
-
import
|
| 7 |
|
| 8 |
API_KEY = os.getenv("YOUTUBE_API_KEY")
|
| 9 |
youtube = build("youtube", "v3", developerKey=API_KEY)
|
|
@@ -13,7 +13,6 @@ def init_db():
|
|
| 13 |
conn = sqlite3.connect("youtube_data.db")
|
| 14 |
c = conn.cursor()
|
| 15 |
|
| 16 |
-
# 영상 데이터 테이블
|
| 17 |
c.execute('''
|
| 18 |
CREATE TABLE IF NOT EXISTS videos (
|
| 19 |
video_id TEXT PRIMARY KEY,
|
|
@@ -26,7 +25,6 @@ def init_db():
|
|
| 26 |
)
|
| 27 |
''')
|
| 28 |
|
| 29 |
-
# 영상 통계 히스토리 (시간별 추적)
|
| 30 |
c.execute('''
|
| 31 |
CREATE TABLE IF NOT EXISTS video_stats (
|
| 32 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -39,7 +37,6 @@ def init_db():
|
|
| 39 |
)
|
| 40 |
''')
|
| 41 |
|
| 42 |
-
# 채널 데이터 테이블
|
| 43 |
c.execute('''
|
| 44 |
CREATE TABLE IF NOT EXISTS channels (
|
| 45 |
channel_id TEXT PRIMARY KEY,
|
|
@@ -48,7 +45,6 @@ def init_db():
|
|
| 48 |
)
|
| 49 |
''')
|
| 50 |
|
| 51 |
-
# 채널 통계 히스토리
|
| 52 |
c.execute('''
|
| 53 |
CREATE TABLE IF NOT EXISTS channel_stats (
|
| 54 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -59,7 +55,6 @@ def init_db():
|
|
| 59 |
)
|
| 60 |
''')
|
| 61 |
|
| 62 |
-
# 검색 기록 테이블
|
| 63 |
c.execute('''
|
| 64 |
CREATE TABLE IF NOT EXISTS search_history (
|
| 65 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -72,7 +67,6 @@ def init_db():
|
|
| 72 |
)
|
| 73 |
''')
|
| 74 |
|
| 75 |
-
# 급상승 알림 테이블
|
| 76 |
c.execute('''
|
| 77 |
CREATE TABLE IF NOT EXISTS trending_alerts (
|
| 78 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -91,54 +85,53 @@ def init_db():
|
|
| 91 |
|
| 92 |
init_db()
|
| 93 |
|
| 94 |
-
# 국가 코드
|
| 95 |
COUNTRIES = {
|
| 96 |
-
"전세계": "",
|
| 97 |
-
"중국": "CN",
|
| 98 |
-
"인도": "IN",
|
| 99 |
-
"미국": "US",
|
| 100 |
-
"인도네시아": "ID",
|
| 101 |
-
"파키스탄": "PK",
|
| 102 |
-
"브라질": "BR",
|
| 103 |
-
"나이지리아": "NG",
|
| 104 |
-
"방글라데시": "BD",
|
| 105 |
-
"러시아": "RU",
|
| 106 |
-
"멕시코": "MX",
|
| 107 |
-
"일본": "JP",
|
| 108 |
-
"에티오피아": "ET",
|
| 109 |
-
"필리핀": "PH",
|
| 110 |
-
"이집트": "EG",
|
| 111 |
-
"베트남": "VN",
|
| 112 |
-
"콩고민주공화국": "CD",
|
| 113 |
-
"터키": "TR",
|
| 114 |
-
"이란": "IR",
|
| 115 |
-
"독일": "DE",
|
| 116 |
-
"태국": "TH",
|
| 117 |
-
"영국": "GB",
|
| 118 |
-
"프랑스": "FR",
|
| 119 |
-
"이탈리아": "IT",
|
| 120 |
-
"남아공": "ZA",
|
| 121 |
-
"탄자니아": "TZ",
|
| 122 |
-
"미얀마": "MM",
|
| 123 |
-
"한국": "KR",
|
| 124 |
-
"콜롬비아": "CO",
|
| 125 |
-
"케냐": "KE",
|
| 126 |
-
"스페인": "ES",
|
| 127 |
-
"아르헨티나": "AR",
|
| 128 |
-
"알제리": "DZ",
|
| 129 |
-
"우크라이나": "UA",
|
| 130 |
-
"이라크": "IQ",
|
| 131 |
-
"캐나다": "CA",
|
| 132 |
-
"폴란드": "PL",
|
| 133 |
-
"모로코": "MA",
|
| 134 |
-
"사우디": "SA",
|
| 135 |
-
"페루": "PE",
|
| 136 |
-
"말레이시아": "MY",
|
| 137 |
}
|
| 138 |
|
| 139 |
-
# 언어 코드 - 사용 인구 순 상위 40개
|
| 140 |
LANGUAGES = {
|
| 141 |
-
"
|
| 142 |
"영어": "en",
|
| 143 |
"중국어": "zh",
|
| 144 |
"힌디어": "hi",
|
|
@@ -205,27 +198,97 @@ def format_count(count):
|
|
| 205 |
return f"{count/1000:.1f}K"
|
| 206 |
return str(count)
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
|
| 209 |
-
"""검색 결과를 DB에 저장"""
|
| 210 |
conn = sqlite3.connect("youtube_data.db")
|
| 211 |
c = conn.cursor()
|
| 212 |
now = datetime.now().isoformat()
|
| 213 |
|
| 214 |
-
# 검색 기록 저장
|
| 215 |
c.execute('''
|
| 216 |
INSERT INTO search_history (keyword, country, language, sort_by, results_count, searched_at)
|
| 217 |
VALUES (?, ?, ?, ?, ?, ?)
|
| 218 |
''', (keyword, country, language, sort_by, len(videos_data), now))
|
| 219 |
|
| 220 |
for video in videos_data:
|
| 221 |
-
# 영상 기본 정보 저장/업데이트
|
| 222 |
c.execute('''
|
| 223 |
INSERT OR IGNORE INTO videos (video_id, title, channel_id, channel_name, thumbnail, published_at, first_seen)
|
| 224 |
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 225 |
''', (video['video_id'], video['title'], video['channel_id'], video['channel_name'],
|
| 226 |
video['thumbnail'], video['published_at'], now))
|
| 227 |
|
| 228 |
-
# 이전 통계 가져오기 (급상승 감지용)
|
| 229 |
c.execute('''
|
| 230 |
SELECT views FROM video_stats
|
| 231 |
WHERE video_id = ?
|
|
@@ -233,13 +296,11 @@ def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
|
|
| 233 |
''', (video['video_id'],))
|
| 234 |
prev_stats = c.fetchone()
|
| 235 |
|
| 236 |
-
# 영상 통계 저장
|
| 237 |
c.execute('''
|
| 238 |
INSERT INTO video_stats (video_id, views, likes, comments, recorded_at)
|
| 239 |
VALUES (?, ?, ?, ?, ?)
|
| 240 |
''', (video['video_id'], video['views'], video['likes'], video['comments'], now))
|
| 241 |
|
| 242 |
-
# 급상승 감지 (조회수 20% 이상 증가)
|
| 243 |
if prev_stats and prev_stats[0] > 0:
|
| 244 |
change_percent = ((video['views'] - prev_stats[0]) / prev_stats[0]) * 100
|
| 245 |
if change_percent >= 20:
|
|
@@ -249,79 +310,44 @@ def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
|
|
| 249 |
''', (video['video_id'], 'views_surge', prev_stats[0], video['views'], change_percent, now))
|
| 250 |
|
| 251 |
for channel_id, subs in channels_data.items():
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
INSERT OR IGNORE INTO channels (channel_id, channel_name, first_seen)
|
| 255 |
-
VALUES (?, ?, ?)
|
| 256 |
-
''', (channel_id, '', now))
|
| 257 |
-
|
| 258 |
-
# 채널 통계 저장
|
| 259 |
if isinstance(subs, int):
|
| 260 |
-
c.execute(''
|
| 261 |
-
|
| 262 |
-
VALUES (?, ?, ?)
|
| 263 |
-
''', (channel_id, subs, now))
|
| 264 |
|
| 265 |
conn.commit()
|
| 266 |
conn.close()
|
| 267 |
|
| 268 |
def get_trending_alerts():
|
| 269 |
-
"""최근 급상승 알림 가져오기"""
|
| 270 |
conn = sqlite3.connect("youtube_data.db")
|
| 271 |
c = conn.cursor()
|
| 272 |
-
|
| 273 |
c.execute('''
|
| 274 |
SELECT ta.video_id, v.title, v.channel_name, ta.alert_type,
|
| 275 |
ta.old_value, ta.new_value, ta.change_percent, ta.detected_at
|
| 276 |
FROM trending_alerts ta
|
| 277 |
JOIN videos v ON ta.video_id = v.video_id
|
| 278 |
-
ORDER BY ta.detected_at DESC
|
| 279 |
-
LIMIT 20
|
| 280 |
''')
|
| 281 |
-
|
| 282 |
alerts = c.fetchall()
|
| 283 |
conn.close()
|
| 284 |
return alerts
|
| 285 |
|
| 286 |
def get_search_history():
|
| 287 |
-
"""검색 기록 가져오기"""
|
| 288 |
conn = sqlite3.connect("youtube_data.db")
|
| 289 |
c = conn.cursor()
|
| 290 |
-
|
| 291 |
c.execute('''
|
| 292 |
SELECT keyword, country, language, sort_by, results_count, searched_at
|
| 293 |
-
FROM search_history
|
| 294 |
-
ORDER BY searched_at DESC
|
| 295 |
-
LIMIT 50
|
| 296 |
''')
|
| 297 |
-
|
| 298 |
-
history = c.fetchall()
|
| 299 |
-
conn.close()
|
| 300 |
-
return history
|
| 301 |
-
|
| 302 |
-
def get_video_history(video_id):
|
| 303 |
-
"""특정 영상의 통계 히스토리"""
|
| 304 |
-
conn = sqlite3.connect("youtube_data.db")
|
| 305 |
-
c = conn.cursor()
|
| 306 |
-
|
| 307 |
-
c.execute('''
|
| 308 |
-
SELECT views, likes, comments, recorded_at
|
| 309 |
-
FROM video_stats
|
| 310 |
-
WHERE video_id = ?
|
| 311 |
-
ORDER BY recorded_at ASC
|
| 312 |
-
''', (video_id,))
|
| 313 |
-
|
| 314 |
history = c.fetchall()
|
| 315 |
conn.close()
|
| 316 |
return history
|
| 317 |
|
| 318 |
def get_top_growing_videos(hours=24):
|
| 319 |
-
"""급성장 영상 TOP 10"""
|
| 320 |
conn = sqlite3.connect("youtube_data.db")
|
| 321 |
c = conn.cursor()
|
| 322 |
-
|
| 323 |
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
|
| 324 |
-
|
| 325 |
c.execute('''
|
| 326 |
SELECT v.video_id, v.title, v.channel_name, v.thumbnail,
|
| 327 |
MIN(vs.views) as min_views, MAX(vs.views) as max_views,
|
|
@@ -331,84 +357,39 @@ def get_top_growing_videos(hours=24):
|
|
| 331 |
WHERE vs.recorded_at > ?
|
| 332 |
GROUP BY v.video_id
|
| 333 |
HAVING min_views > 0 AND max_views > min_views
|
| 334 |
-
ORDER BY growth_rate DESC
|
| 335 |
-
LIMIT 10
|
| 336 |
''', (cutoff,))
|
| 337 |
-
|
| 338 |
results = c.fetchall()
|
| 339 |
conn.close()
|
| 340 |
return results
|
| 341 |
|
| 342 |
def get_db_stats():
|
| 343 |
-
"""DB 통계"""
|
| 344 |
conn = sqlite3.connect("youtube_data.db")
|
| 345 |
c = conn.cursor()
|
| 346 |
-
|
| 347 |
c.execute("SELECT COUNT(*) FROM videos")
|
| 348 |
video_count = c.fetchone()[0]
|
| 349 |
-
|
| 350 |
c.execute("SELECT COUNT(*) FROM video_stats")
|
| 351 |
stats_count = c.fetchone()[0]
|
| 352 |
-
|
| 353 |
c.execute("SELECT COUNT(*) FROM channels")
|
| 354 |
channel_count = c.fetchone()[0]
|
| 355 |
-
|
| 356 |
c.execute("SELECT COUNT(*) FROM search_history")
|
| 357 |
search_count = c.fetchone()[0]
|
| 358 |
-
|
| 359 |
c.execute("SELECT COUNT(*) FROM trending_alerts")
|
| 360 |
alert_count = c.fetchone()[0]
|
| 361 |
-
|
| 362 |
conn.close()
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
"videos": video_count,
|
| 366 |
-
"stats_records": stats_count,
|
| 367 |
-
"channels": channel_count,
|
| 368 |
-
"searches": search_count,
|
| 369 |
-
"alerts": alert_count
|
| 370 |
-
}
|
| 371 |
-
|
| 372 |
-
def get_trending_keywords(region_code="KR"):
|
| 373 |
-
try:
|
| 374 |
-
response = youtube.videos().list(
|
| 375 |
-
part="snippet",
|
| 376 |
-
chart="mostPopular",
|
| 377 |
-
regionCode=region_code if region_code else "US",
|
| 378 |
-
maxResults=20
|
| 379 |
-
).execute()
|
| 380 |
-
|
| 381 |
-
keywords = []
|
| 382 |
-
seen = set()
|
| 383 |
-
for item in response.get("items", []):
|
| 384 |
-
channel = item["snippet"]["channelTitle"]
|
| 385 |
-
keyword = channel.split()[0] if len(channel.split()) > 0 else item["snippet"]["title"].split()[0]
|
| 386 |
-
if keyword not in seen and len(keyword) > 1:
|
| 387 |
-
keywords.append(keyword)
|
| 388 |
-
seen.add(keyword)
|
| 389 |
-
if len(keywords) >= 15:
|
| 390 |
-
break
|
| 391 |
-
|
| 392 |
-
return keywords if keywords else get_default_trending()
|
| 393 |
-
except:
|
| 394 |
-
return get_default_trending()
|
| 395 |
-
|
| 396 |
-
def get_default_trending():
|
| 397 |
-
return [
|
| 398 |
-
"AI", "ChatGPT", "뉴진스", "BTS", "Netflix",
|
| 399 |
-
"게임", "먹방", "브이로그", "shorts", "음악",
|
| 400 |
-
"축구", "NBA", "애니메이션", "영화 리뷰", "코딩"
|
| 401 |
-
]
|
| 402 |
|
| 403 |
def update_trending(country):
|
| 404 |
-
|
| 405 |
-
|
|
|
|
| 406 |
return gr.update(choices=keywords, value=None)
|
| 407 |
|
| 408 |
def use_trending_keyword(trending_keyword):
|
| 409 |
return trending_keyword if trending_keyword else ""
|
| 410 |
|
| 411 |
-
def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
| 412 |
if not keyword or not keyword.strip():
|
| 413 |
return "검색어를 입력하세요.", ""
|
| 414 |
|
|
@@ -416,6 +397,15 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 416 |
all_items = []
|
| 417 |
next_page_token = None
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
search_params = {
|
| 420 |
"q": keyword,
|
| 421 |
"part": "snippet",
|
|
@@ -423,10 +413,11 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 423 |
"order": SORT_OPTIONS[sort_by],
|
| 424 |
}
|
| 425 |
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
| 430 |
|
| 431 |
if date_filter != "전체 기간":
|
| 432 |
now = datetime.utcnow()
|
|
@@ -450,7 +441,7 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 450 |
|
| 451 |
if not items:
|
| 452 |
break
|
| 453 |
-
|
| 454 |
all_items.extend(items)
|
| 455 |
next_page_token = response.get("nextPageToken")
|
| 456 |
|
|
@@ -466,11 +457,7 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 466 |
video_stats = {}
|
| 467 |
for i in range(0, len(video_ids), 50):
|
| 468 |
batch_ids = video_ids[i:i+50]
|
| 469 |
-
video_details = youtube.videos().list(
|
| 470 |
-
id=",".join(batch_ids),
|
| 471 |
-
part="statistics"
|
| 472 |
-
).execute()
|
| 473 |
-
|
| 474 |
for v in video_details.get("items", []):
|
| 475 |
stats = v["statistics"]
|
| 476 |
video_stats[v["id"]] = {
|
|
@@ -486,11 +473,7 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 486 |
channel_subs_raw = {}
|
| 487 |
for i in range(0, len(channel_ids), 50):
|
| 488 |
batch_ids = channel_ids[i:i+50]
|
| 489 |
-
channel_details = youtube.channels().list(
|
| 490 |
-
id=",".join(batch_ids),
|
| 491 |
-
part="statistics"
|
| 492 |
-
).execute()
|
| 493 |
-
|
| 494 |
for ch in channel_details.get("items", []):
|
| 495 |
sub_count = ch["statistics"].get("subscriberCount", "0")
|
| 496 |
if sub_count:
|
|
@@ -499,13 +482,11 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 499 |
else:
|
| 500 |
channel_subs[ch["id"]] = "비공개"
|
| 501 |
|
| 502 |
-
# DB 저장용 데이터 준비
|
| 503 |
videos_data = []
|
| 504 |
for item in all_items:
|
| 505 |
video_id = item["id"]["videoId"]
|
| 506 |
snippet = item["snippet"]
|
| 507 |
stats = video_stats.get(video_id, {})
|
| 508 |
-
|
| 509 |
videos_data.append({
|
| 510 |
"video_id": video_id,
|
| 511 |
"title": snippet["title"],
|
|
@@ -518,15 +499,22 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 518 |
"comments": stats.get("comments", 0),
|
| 519 |
})
|
| 520 |
|
| 521 |
-
|
| 522 |
-
if save_db:
|
| 523 |
save_to_db(videos_data, channel_subs_raw, keyword, country, language, sort_by)
|
| 524 |
|
| 525 |
-
#
|
| 526 |
html = f'''
|
| 527 |
<style>
|
| 528 |
.yt-table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
|
| 529 |
-
.yt-table th {{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
.yt-table td {{ padding: 10px 8px; border-bottom: 1px solid #ddd; vertical-align: middle; }}
|
| 531 |
.yt-table tr:hover {{ background: #f5f5f5; }}
|
| 532 |
.yt-table img {{ border-radius: 4px; }}
|
|
@@ -536,22 +524,24 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 536 |
.yt-views {{ font-weight: bold; color: #065fd4; }}
|
| 537 |
.yt-subs {{ color: #606060; }}
|
| 538 |
.yt-date {{ color: #888; font-size: 12px; }}
|
| 539 |
-
.surge {{ background: #fff3cd !important; }}
|
| 540 |
</style>
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
| 542 |
<div style="max-height: 800px; overflow-y: auto;">
|
| 543 |
-
<table class="yt-table">
|
| 544 |
<thead>
|
| 545 |
<tr>
|
| 546 |
-
<th style="width:50px;">순위</th>
|
| 547 |
-
<th style="width:120px;">썸네일</th>
|
| 548 |
-
<th>제목</th>
|
| 549 |
-
<th style="width:150px;">채널</th>
|
| 550 |
-
<th style="width:80px;">구독자</th>
|
| 551 |
-
<th style="width:80px;">조회수</th>
|
| 552 |
-
<th style="width:60px;">좋아요</th>
|
| 553 |
-
<th style="width:60px;">댓글</th>
|
| 554 |
-
<th style="width:90px;">게시일</th>
|
| 555 |
</tr>
|
| 556 |
</thead>
|
| 557 |
<tbody>
|
|
@@ -563,21 +553,23 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 563 |
channel_id = snippet["channelId"]
|
| 564 |
|
| 565 |
title = snippet["title"]
|
| 566 |
-
if len(title) > 50
|
| 567 |
-
title = title[:50] + "..."
|
| 568 |
channel = snippet["channelTitle"]
|
| 569 |
-
if len(channel) > 15
|
| 570 |
-
channel = channel[:15] + "..."
|
| 571 |
thumbnail = snippet["thumbnails"]["default"]["url"]
|
| 572 |
published = snippet["publishedAt"][:10]
|
| 573 |
url = f"https://youtube.com/watch?v={video_id}"
|
| 574 |
channel_url = f"https://youtube.com/channel/{channel_id}"
|
| 575 |
|
| 576 |
stats = video_stats.get(video_id, {})
|
| 577 |
-
views = stats.get("
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
|
| 582 |
if i == 1:
|
| 583 |
rank_style = "color: #FFD700; font-size: 18px;"
|
|
@@ -590,173 +582,154 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
|
|
| 590 |
|
| 591 |
html += f'''
|
| 592 |
<tr>
|
| 593 |
-
<td class="yt-rank" style="{rank_style}">{i}</td>
|
| 594 |
<td><a href="{url}" target="_blank"><img src="{thumbnail}" width="100"></a></td>
|
| 595 |
-
<td><a href="{url}" target="_blank">{
|
| 596 |
-
<td><a href="{channel_url}" target="_blank">{
|
| 597 |
-
<td class="yt-subs">{
|
| 598 |
-
<td class="yt-views"
|
| 599 |
-
<td
|
| 600 |
-
<td
|
| 601 |
-
<td class="yt-date">{published}</td>
|
| 602 |
</tr>
|
| 603 |
'''
|
| 604 |
|
| 605 |
-
html += '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
|
| 607 |
-
# DB 통계 업데이트
|
| 608 |
db_stats = get_db_stats()
|
| 609 |
-
stats_html = f"📊 DB: 영상 {db_stats['videos']}개 | 기록 {db_stats['stats_records']}건 | 채널 {db_stats['channels']}개 | 검색 {db_stats['searches']}회
|
| 610 |
|
| 611 |
return html, stats_html
|
| 612 |
|
| 613 |
def show_trending_alerts():
|
| 614 |
-
"""급상승 알림 표시"""
|
| 615 |
alerts = get_trending_alerts()
|
| 616 |
-
|
| 617 |
if not alerts:
|
| 618 |
-
return "<p>
|
| 619 |
|
| 620 |
html = '''
|
| 621 |
-
<style>
|
| 622 |
-
.alert-table { width: 100%; border-collapse: collapse; }
|
| 623 |
-
.alert-table th { background: #ff6b6b; color: white; padding: 10px; }
|
| 624 |
-
.alert-table td { padding: 10px; border-bottom: 1px solid #ddd; }
|
| 625 |
-
.surge-badge { background: #ff0000; color: white; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
| 626 |
-
</style>
|
| 627 |
<h3>🔥 급상승 알림 (조회수 20% 이상 증가)</h3>
|
| 628 |
-
<table
|
| 629 |
-
<thead>
|
| 630 |
-
<
|
| 631 |
-
|
| 632 |
-
<th>채널</th>
|
| 633 |
-
<th>이전</th>
|
| 634 |
-
<th>현재</th>
|
| 635 |
-
<th>증가율</th>
|
| 636 |
-
<th>감지 시간</th>
|
| 637 |
-
</tr>
|
| 638 |
-
</thead>
|
| 639 |
-
<tbody>
|
| 640 |
'''
|
| 641 |
-
|
| 642 |
for alert in alerts:
|
| 643 |
-
video_id, title, channel,
|
| 644 |
title = title[:30] + "..." if len(title) > 30 else title
|
| 645 |
url = f"https://youtube.com/watch?v={video_id}"
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
<
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
<td>{format_count(old_val)}</td>
|
| 653 |
-
<td>{format_count(new_val)}</td>
|
| 654 |
-
<td><span class="surge-badge">+{change_pct:.1f}%</span></td>
|
| 655 |
-
<td>{detected_fmt}</td>
|
| 656 |
-
</tr>
|
| 657 |
-
'''
|
| 658 |
-
|
| 659 |
html += '</tbody></table>'
|
| 660 |
return html
|
| 661 |
|
| 662 |
def show_top_growing():
|
| 663 |
-
"""급성장 영상 TOP 10"""
|
| 664 |
results = get_top_growing_videos(24)
|
| 665 |
-
|
| 666 |
if not results:
|
| 667 |
-
return "<p>최근 24시간 내 급성장
|
| 668 |
-
|
| 669 |
-
html = '''
|
| 670 |
-
<h3>📈 24시간 급성장 TOP 10</h3>
|
| 671 |
-
<div style="display: flex; flex-wrap: wrap; gap: 15px;">
|
| 672 |
-
'''
|
| 673 |
|
| 674 |
-
|
|
|
|
| 675 |
title = title[:25] + "..." if len(title) > 25 else title
|
| 676 |
url = f"https://youtube.com/watch?v={video_id}"
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
<
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
<p style="margin: 5px 0; font-size: 12px;"><b>{i}.</b> {title}</p>
|
| 684 |
-
<p style="margin: 0; font-size: 11px; color: #666;">{channel}</p>
|
| 685 |
-
<p style="margin: 5px 0; font-size: 14px; color: #ff0000; font-weight: bold;">📈 +{growth:.1f}%</p>
|
| 686 |
-
<p style="margin: 0; font-size: 10px; color: #888;">{format_count(min_views)} → {format_count(max_views)}</p>
|
| 687 |
-
</div>
|
| 688 |
-
'''
|
| 689 |
-
|
| 690 |
html += '</div>'
|
| 691 |
return html
|
| 692 |
|
| 693 |
def show_search_history():
|
| 694 |
-
"""검색 기록 표시"""
|
| 695 |
history = get_search_history()
|
| 696 |
-
|
| 697 |
if not history:
|
| 698 |
return "<p>검색 기록이 없습니다.</p>"
|
| 699 |
|
| 700 |
-
html = '''
|
| 701 |
-
<
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
<th style="padding: 10px;">검색 시간</th>
|
| 711 |
-
</tr>
|
| 712 |
-
</thead>
|
| 713 |
-
<tbody>
|
| 714 |
-
'''
|
| 715 |
-
|
| 716 |
-
for keyword, country, language, sort_by, count, searched_at in history:
|
| 717 |
-
searched_fmt = searched_at[:16].replace("T", " ")
|
| 718 |
-
html += f'''
|
| 719 |
-
<tr style="border-bottom: 1px solid #ddd;">
|
| 720 |
-
<td style="padding: 8px;"><b>{keyword}</b></td>
|
| 721 |
-
<td style="padding: 8px;">{country}</td>
|
| 722 |
-
<td style="padding: 8px;">{language}</td>
|
| 723 |
-
<td style="padding: 8px;">{sort_by}</td>
|
| 724 |
-
<td style="padding: 8px;">{count}</td>
|
| 725 |
-
<td style="padding: 8px;">{searched_fmt}</td>
|
| 726 |
-
</tr>
|
| 727 |
-
'''
|
| 728 |
-
|
| 729 |
html += '</tbody></table>'
|
| 730 |
return html
|
| 731 |
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
db_stats = get_db_stats()
|
| 735 |
-
alerts = show_trending_alerts()
|
| 736 |
-
growing = show_top_growing()
|
| 737 |
-
history = show_search_history()
|
| 738 |
-
stats_html = f"📊 DB: 영상 {db_stats['videos']}개 | 기록 {db_stats['stats_records']}건 | 채널 {db_stats['channels']}개 | 검색 {db_stats['searches']}회 | 알림 {db_stats['alerts']}건"
|
| 739 |
-
|
| 740 |
-
return alerts, growing, history, stats_html
|
| 741 |
-
|
| 742 |
-
# 초기 트렌딩 키워드
|
| 743 |
-
initial_trending = get_default_trending()
|
| 744 |
|
| 745 |
with gr.Blocks() as demo:
|
| 746 |
gr.Markdown("## YouTube 인기 영상 검색기 + 트렌드 분석")
|
| 747 |
|
| 748 |
-
# DB 통계
|
| 749 |
db_stats_display = gr.Markdown("📊 DB 통계 로딩중...")
|
| 750 |
|
| 751 |
with gr.Tabs():
|
| 752 |
-
# 탭 1: 검색
|
| 753 |
with gr.Tab("���� 검색"):
|
| 754 |
-
gr.Markdown("### 🔥 실시간 인기 검색어")
|
| 755 |
-
trending_radio = gr.Radio(
|
| 756 |
-
choices=initial_trending,
|
| 757 |
-
label="클릭하면 검색창에 자동 입력",
|
| 758 |
-
interactive=True
|
| 759 |
-
)
|
| 760 |
|
| 761 |
with gr.Row():
|
| 762 |
keyword = gr.Textbox(label="검색어", placeholder="검색어 입력...", scale=3)
|
|
@@ -765,51 +738,29 @@ with gr.Blocks() as demo:
|
|
| 765 |
|
| 766 |
with gr.Row():
|
| 767 |
country = gr.Dropdown(list(COUNTRIES.keys()), value="한국", label="국가 (인구순)")
|
| 768 |
-
language = gr.Dropdown(list(LANGUAGES.keys()), value="
|
| 769 |
sort_by = gr.Dropdown(list(SORT_OPTIONS.keys()), value="조회수 순", label="정렬")
|
| 770 |
date_filter = gr.Dropdown(list(DATE_OPTIONS.keys()), value="전체 기간", label="기간")
|
| 771 |
max_results = gr.Slider(10, 300, value=50, step=10, label="결과 수")
|
| 772 |
|
| 773 |
search_output = gr.HTML()
|
| 774 |
|
| 775 |
-
# 탭 2: 급상승
|
| 776 |
with gr.Tab("🔥 급상승"):
|
| 777 |
-
|
| 778 |
alerts_output = gr.HTML()
|
| 779 |
|
| 780 |
-
# 탭 3: 급성장
|
| 781 |
with gr.Tab("📈 급성장 TOP"):
|
| 782 |
-
|
| 783 |
growing_output = gr.HTML()
|
| 784 |
|
| 785 |
-
# 탭 4: 검색 기록
|
| 786 |
with gr.Tab("🕐 검색 기록"):
|
| 787 |
-
|
| 788 |
history_output = gr.HTML()
|
| 789 |
|
| 790 |
-
# 이벤트 연결
|
| 791 |
trending_radio.change(use_trending_keyword, trending_radio, keyword)
|
| 792 |
country.change(update_trending, country, trending_radio)
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
[keyword, country, language, sort_by, date_filter, max_results],
|
| 797 |
-
[search_output, db_stats_display]
|
| 798 |
-
)
|
| 799 |
-
keyword.submit(
|
| 800 |
-
search_videos,
|
| 801 |
-
[keyword, country, language, sort_by, date_filter, max_results],
|
| 802 |
-
[search_output, db_stats_display]
|
| 803 |
-
)
|
| 804 |
-
|
| 805 |
-
refresh_btn.click(
|
| 806 |
-
search_videos,
|
| 807 |
-
[keyword, country, language, sort_by, date_filter, max_results],
|
| 808 |
-
[search_output, db_stats_display]
|
| 809 |
-
)
|
| 810 |
-
|
| 811 |
-
refresh_alerts_btn.click(show_trending_alerts, outputs=alerts_output)
|
| 812 |
-
refresh_growing_btn.click(show_top_growing, outputs=growing_output)
|
| 813 |
-
refresh_history_btn.click(show_search_history, outputs=history_output)
|
| 814 |
|
| 815 |
demo.launch()
|
|
|
|
| 3 |
import os
|
| 4 |
import sqlite3
|
| 5 |
from datetime import datetime, timedelta
|
| 6 |
+
import re
|
| 7 |
|
| 8 |
API_KEY = os.getenv("YOUTUBE_API_KEY")
|
| 9 |
youtube = build("youtube", "v3", developerKey=API_KEY)
|
|
|
|
| 13 |
conn = sqlite3.connect("youtube_data.db")
|
| 14 |
c = conn.cursor()
|
| 15 |
|
|
|
|
| 16 |
c.execute('''
|
| 17 |
CREATE TABLE IF NOT EXISTS videos (
|
| 18 |
video_id TEXT PRIMARY KEY,
|
|
|
|
| 25 |
)
|
| 26 |
''')
|
| 27 |
|
|
|
|
| 28 |
c.execute('''
|
| 29 |
CREATE TABLE IF NOT EXISTS video_stats (
|
| 30 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 37 |
)
|
| 38 |
''')
|
| 39 |
|
|
|
|
| 40 |
c.execute('''
|
| 41 |
CREATE TABLE IF NOT EXISTS channels (
|
| 42 |
channel_id TEXT PRIMARY KEY,
|
|
|
|
| 45 |
)
|
| 46 |
''')
|
| 47 |
|
|
|
|
| 48 |
c.execute('''
|
| 49 |
CREATE TABLE IF NOT EXISTS channel_stats (
|
| 50 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 55 |
)
|
| 56 |
''')
|
| 57 |
|
|
|
|
| 58 |
c.execute('''
|
| 59 |
CREATE TABLE IF NOT EXISTS search_history (
|
| 60 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 67 |
)
|
| 68 |
''')
|
| 69 |
|
|
|
|
| 70 |
c.execute('''
|
| 71 |
CREATE TABLE IF NOT EXISTS trending_alerts (
|
| 72 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 85 |
|
| 86 |
init_db()
|
| 87 |
|
| 88 |
+
# 국가 코드 + 기본 언어 매핑
|
| 89 |
COUNTRIES = {
|
| 90 |
+
"전세계": ("", ""),
|
| 91 |
+
"중국": ("CN", "zh"),
|
| 92 |
+
"인도": ("IN", "hi"),
|
| 93 |
+
"미국": ("US", "en"),
|
| 94 |
+
"인도네시아": ("ID", "id"),
|
| 95 |
+
"파키스탄": ("PK", "ur"),
|
| 96 |
+
"브라질": ("BR", "pt"),
|
| 97 |
+
"나이지리아": ("NG", "en"),
|
| 98 |
+
"방글라데시": ("BD", "bn"),
|
| 99 |
+
"러시아": ("RU", "ru"),
|
| 100 |
+
"멕시코": ("MX", "es"),
|
| 101 |
+
"일본": ("JP", "ja"),
|
| 102 |
+
"에티오피아": ("ET", "am"),
|
| 103 |
+
"필리핀": ("PH", "tl"),
|
| 104 |
+
"이집트": ("EG", "ar"),
|
| 105 |
+
"베트남": ("VN", "vi"),
|
| 106 |
+
"콩고민주공화국": ("CD", "fr"),
|
| 107 |
+
"터키": ("TR", "tr"),
|
| 108 |
+
"이란": ("IR", "fa"),
|
| 109 |
+
"독일": ("DE", "de"),
|
| 110 |
+
"태국": ("TH", "th"),
|
| 111 |
+
"영국": ("GB", "en"),
|
| 112 |
+
"프랑스": ("FR", "fr"),
|
| 113 |
+
"이탈리아": ("IT", "it"),
|
| 114 |
+
"남아공": ("ZA", "en"),
|
| 115 |
+
"탄자니아": ("TZ", "sw"),
|
| 116 |
+
"미얀마": ("MM", "my"),
|
| 117 |
+
"한국": ("KR", "ko"),
|
| 118 |
+
"콜롬비아": ("CO", "es"),
|
| 119 |
+
"케냐": ("KE", "sw"),
|
| 120 |
+
"스페인": ("ES", "es"),
|
| 121 |
+
"아르헨티나": ("AR", "es"),
|
| 122 |
+
"알제리": ("DZ", "ar"),
|
| 123 |
+
"우크라이나": ("UA", "uk"),
|
| 124 |
+
"이라크": ("IQ", "ar"),
|
| 125 |
+
"캐나다": ("CA", "en"),
|
| 126 |
+
"폴란드": ("PL", "pl"),
|
| 127 |
+
"모로코": ("MA", "ar"),
|
| 128 |
+
"사우디": ("SA", "ar"),
|
| 129 |
+
"페루": ("PE", "es"),
|
| 130 |
+
"말레이시아": ("MY", "ms"),
|
| 131 |
}
|
| 132 |
|
|
|
|
| 133 |
LANGUAGES = {
|
| 134 |
+
"자동 (국가 기반)": "",
|
| 135 |
"영어": "en",
|
| 136 |
"중국어": "zh",
|
| 137 |
"힌디어": "hi",
|
|
|
|
| 198 |
return f"{count/1000:.1f}K"
|
| 199 |
return str(count)
|
| 200 |
|
| 201 |
+
def get_real_trending_keywords(region_code="KR", language="ko"):
|
| 202 |
+
"""YouTube 인기 동영상에서 실제 트렌딩 키워드 추출"""
|
| 203 |
+
try:
|
| 204 |
+
# 1. 인기 동영상 가져오기
|
| 205 |
+
response = youtube.videos().list(
|
| 206 |
+
part="snippet",
|
| 207 |
+
chart="mostPopular",
|
| 208 |
+
regionCode=region_code if region_code else "US",
|
| 209 |
+
maxResults=50
|
| 210 |
+
).execute()
|
| 211 |
+
|
| 212 |
+
keywords = []
|
| 213 |
+
seen = set()
|
| 214 |
+
|
| 215 |
+
for item in response.get("items", []):
|
| 216 |
+
title = item["snippet"]["title"]
|
| 217 |
+
channel = item["snippet"]["channelTitle"]
|
| 218 |
+
tags = item["snippet"].get("tags", [])
|
| 219 |
+
|
| 220 |
+
# 태그에서 키워드 추출
|
| 221 |
+
for tag in tags[:3]:
|
| 222 |
+
tag_clean = tag.strip().lower()
|
| 223 |
+
if tag_clean not in seen and 2 <= len(tag_clean) <= 20:
|
| 224 |
+
keywords.append(tag)
|
| 225 |
+
seen.add(tag_clean)
|
| 226 |
+
|
| 227 |
+
# 채널명 추출
|
| 228 |
+
if channel not in seen and len(channel) > 1:
|
| 229 |
+
keywords.append(channel)
|
| 230 |
+
seen.add(channel.lower())
|
| 231 |
+
|
| 232 |
+
# 제목에서 주요 단어 추출 (한글/영문)
|
| 233 |
+
if region_code == "KR":
|
| 234 |
+
words = re.findall(r'[가-힣]{2,10}', title)
|
| 235 |
+
else:
|
| 236 |
+
words = re.findall(r'[A-Za-z]{3,15}', title)
|
| 237 |
+
|
| 238 |
+
for word in words[:2]:
|
| 239 |
+
word_lower = word.lower()
|
| 240 |
+
if word_lower not in seen and word_lower not in ['the', 'and', 'for', 'with', 'this', 'that', 'from']:
|
| 241 |
+
keywords.append(word)
|
| 242 |
+
seen.add(word_lower)
|
| 243 |
+
|
| 244 |
+
if len(keywords) >= 30:
|
| 245 |
+
break
|
| 246 |
+
|
| 247 |
+
# 2. 인기 검색어 추가 (카테고리별)
|
| 248 |
+
popular_categories = {
|
| 249 |
+
"KR": ["뉴진스", "BTS", "먹방", "브이로그", "게임", "shorts", "드라마", "영화", "음악", "코딩", "AI", "축구", "야구", "아이돌", "뷰티"],
|
| 250 |
+
"US": ["Taylor Swift", "NBA", "NFL", "gaming", "mukbang", "vlog", "music", "movie", "AI", "coding", "shorts", "podcast", "news", "tech", "finance"],
|
| 251 |
+
"JP": ["アニメ", "ゲーム", "音楽", "vlog", "料理", "猫", "犬", "ニュース", "映画", "ドラマ", "歌ってみた", "踊ってみた", "ASMR", "shorts", "AI"],
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
extra = popular_categories.get(region_code, popular_categories["US"])
|
| 255 |
+
for kw in extra:
|
| 256 |
+
if kw.lower() not in seen:
|
| 257 |
+
keywords.append(kw)
|
| 258 |
+
seen.add(kw.lower())
|
| 259 |
+
|
| 260 |
+
return keywords[:20] if keywords else get_default_trending(region_code)
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
print(f"트렌딩 키워드 오류: {e}")
|
| 264 |
+
return get_default_trending(region_code)
|
| 265 |
+
|
| 266 |
+
def get_default_trending(region_code="KR"):
|
| 267 |
+
"""기본 인기 검색어 (국가별)"""
|
| 268 |
+
defaults = {
|
| 269 |
+
"KR": ["AI", "ChatGPT", "뉴진스", "BTS", "게임", "먹방", "브이로그", "shorts", "음악", "축구", "드라마", "영화", "코딩", "주식", "부동산"],
|
| 270 |
+
"US": ["AI", "ChatGPT", "gaming", "music", "NBA", "NFL", "vlog", "shorts", "movie", "news", "tech", "finance", "cooking", "fitness", "travel"],
|
| 271 |
+
"JP": ["AI", "アニメ", "ゲーム", "音楽", "vlog", "料理", "ニュース", "映画", "shorts", "ASMR", "猫", "旅行", "ファッション", "コスメ", "勉強"],
|
| 272 |
+
}
|
| 273 |
+
return defaults.get(region_code, defaults["US"])
|
| 274 |
+
|
| 275 |
def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
|
|
|
|
| 276 |
conn = sqlite3.connect("youtube_data.db")
|
| 277 |
c = conn.cursor()
|
| 278 |
now = datetime.now().isoformat()
|
| 279 |
|
|
|
|
| 280 |
c.execute('''
|
| 281 |
INSERT INTO search_history (keyword, country, language, sort_by, results_count, searched_at)
|
| 282 |
VALUES (?, ?, ?, ?, ?, ?)
|
| 283 |
''', (keyword, country, language, sort_by, len(videos_data), now))
|
| 284 |
|
| 285 |
for video in videos_data:
|
|
|
|
| 286 |
c.execute('''
|
| 287 |
INSERT OR IGNORE INTO videos (video_id, title, channel_id, channel_name, thumbnail, published_at, first_seen)
|
| 288 |
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 289 |
''', (video['video_id'], video['title'], video['channel_id'], video['channel_name'],
|
| 290 |
video['thumbnail'], video['published_at'], now))
|
| 291 |
|
|
|
|
| 292 |
c.execute('''
|
| 293 |
SELECT views FROM video_stats
|
| 294 |
WHERE video_id = ?
|
|
|
|
| 296 |
''', (video['video_id'],))
|
| 297 |
prev_stats = c.fetchone()
|
| 298 |
|
|
|
|
| 299 |
c.execute('''
|
| 300 |
INSERT INTO video_stats (video_id, views, likes, comments, recorded_at)
|
| 301 |
VALUES (?, ?, ?, ?, ?)
|
| 302 |
''', (video['video_id'], video['views'], video['likes'], video['comments'], now))
|
| 303 |
|
|
|
|
| 304 |
if prev_stats and prev_stats[0] > 0:
|
| 305 |
change_percent = ((video['views'] - prev_stats[0]) / prev_stats[0]) * 100
|
| 306 |
if change_percent >= 20:
|
|
|
|
| 310 |
''', (video['video_id'], 'views_surge', prev_stats[0], video['views'], change_percent, now))
|
| 311 |
|
| 312 |
for channel_id, subs in channels_data.items():
|
| 313 |
+
c.execute('INSERT OR IGNORE INTO channels (channel_id, channel_name, first_seen) VALUES (?, ?, ?)',
|
| 314 |
+
(channel_id, '', now))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
if isinstance(subs, int):
|
| 316 |
+
c.execute('INSERT INTO channel_stats (channel_id, subscribers, recorded_at) VALUES (?, ?, ?)',
|
| 317 |
+
(channel_id, subs, now))
|
|
|
|
|
|
|
| 318 |
|
| 319 |
conn.commit()
|
| 320 |
conn.close()
|
| 321 |
|
| 322 |
def get_trending_alerts():
|
|
|
|
| 323 |
conn = sqlite3.connect("youtube_data.db")
|
| 324 |
c = conn.cursor()
|
|
|
|
| 325 |
c.execute('''
|
| 326 |
SELECT ta.video_id, v.title, v.channel_name, ta.alert_type,
|
| 327 |
ta.old_value, ta.new_value, ta.change_percent, ta.detected_at
|
| 328 |
FROM trending_alerts ta
|
| 329 |
JOIN videos v ON ta.video_id = v.video_id
|
| 330 |
+
ORDER BY ta.detected_at DESC LIMIT 20
|
|
|
|
| 331 |
''')
|
|
|
|
| 332 |
alerts = c.fetchall()
|
| 333 |
conn.close()
|
| 334 |
return alerts
|
| 335 |
|
| 336 |
def get_search_history():
|
|
|
|
| 337 |
conn = sqlite3.connect("youtube_data.db")
|
| 338 |
c = conn.cursor()
|
|
|
|
| 339 |
c.execute('''
|
| 340 |
SELECT keyword, country, language, sort_by, results_count, searched_at
|
| 341 |
+
FROM search_history ORDER BY searched_at DESC LIMIT 50
|
|
|
|
|
|
|
| 342 |
''')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
history = c.fetchall()
|
| 344 |
conn.close()
|
| 345 |
return history
|
| 346 |
|
| 347 |
def get_top_growing_videos(hours=24):
|
|
|
|
| 348 |
conn = sqlite3.connect("youtube_data.db")
|
| 349 |
c = conn.cursor()
|
|
|
|
| 350 |
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
|
|
|
|
| 351 |
c.execute('''
|
| 352 |
SELECT v.video_id, v.title, v.channel_name, v.thumbnail,
|
| 353 |
MIN(vs.views) as min_views, MAX(vs.views) as max_views,
|
|
|
|
| 357 |
WHERE vs.recorded_at > ?
|
| 358 |
GROUP BY v.video_id
|
| 359 |
HAVING min_views > 0 AND max_views > min_views
|
| 360 |
+
ORDER BY growth_rate DESC LIMIT 10
|
|
|
|
| 361 |
''', (cutoff,))
|
|
|
|
| 362 |
results = c.fetchall()
|
| 363 |
conn.close()
|
| 364 |
return results
|
| 365 |
|
| 366 |
def get_db_stats():
|
|
|
|
| 367 |
conn = sqlite3.connect("youtube_data.db")
|
| 368 |
c = conn.cursor()
|
|
|
|
| 369 |
c.execute("SELECT COUNT(*) FROM videos")
|
| 370 |
video_count = c.fetchone()[0]
|
|
|
|
| 371 |
c.execute("SELECT COUNT(*) FROM video_stats")
|
| 372 |
stats_count = c.fetchone()[0]
|
|
|
|
| 373 |
c.execute("SELECT COUNT(*) FROM channels")
|
| 374 |
channel_count = c.fetchone()[0]
|
|
|
|
| 375 |
c.execute("SELECT COUNT(*) FROM search_history")
|
| 376 |
search_count = c.fetchone()[0]
|
|
|
|
| 377 |
c.execute("SELECT COUNT(*) FROM trending_alerts")
|
| 378 |
alert_count = c.fetchone()[0]
|
|
|
|
| 379 |
conn.close()
|
| 380 |
+
return {"videos": video_count, "stats_records": stats_count, "channels": channel_count,
|
| 381 |
+
"searches": search_count, "alerts": alert_count}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
def update_trending(country):
|
| 384 |
+
"""국가 변경시 트렌딩 키워드 + 언어 자동 업데이트"""
|
| 385 |
+
region, default_lang = COUNTRIES.get(country, ("", ""))
|
| 386 |
+
keywords = get_real_trending_keywords(region if region else "US", default_lang)
|
| 387 |
return gr.update(choices=keywords, value=None)
|
| 388 |
|
| 389 |
def use_trending_keyword(trending_keyword):
|
| 390 |
return trending_keyword if trending_keyword else ""
|
| 391 |
|
| 392 |
+
def search_videos(keyword, country, language, sort_by, date_filter, max_results, save_db_flag=True):
|
| 393 |
if not keyword or not keyword.strip():
|
| 394 |
return "검색어를 입력하세요.", ""
|
| 395 |
|
|
|
|
| 397 |
all_items = []
|
| 398 |
next_page_token = None
|
| 399 |
|
| 400 |
+
# 국가 코드 + 기본 언어 가져오기
|
| 401 |
+
region_code, default_lang = COUNTRIES.get(country, ("", ""))
|
| 402 |
+
|
| 403 |
+
# 언어 설정: "자동"이면 국가 기본 언어 사용
|
| 404 |
+
if language == "자동 (국가 기반)":
|
| 405 |
+
lang_code = default_lang
|
| 406 |
+
else:
|
| 407 |
+
lang_code = LANGUAGES.get(language, "")
|
| 408 |
+
|
| 409 |
search_params = {
|
| 410 |
"q": keyword,
|
| 411 |
"part": "snippet",
|
|
|
|
| 413 |
"order": SORT_OPTIONS[sort_by],
|
| 414 |
}
|
| 415 |
|
| 416 |
+
# 국가 + 언어 둘 다 적용
|
| 417 |
+
if region_code:
|
| 418 |
+
search_params["regionCode"] = region_code
|
| 419 |
+
if lang_code:
|
| 420 |
+
search_params["relevanceLanguage"] = lang_code
|
| 421 |
|
| 422 |
if date_filter != "전체 기간":
|
| 423 |
now = datetime.utcnow()
|
|
|
|
| 441 |
|
| 442 |
if not items:
|
| 443 |
break
|
| 444 |
+
|
| 445 |
all_items.extend(items)
|
| 446 |
next_page_token = response.get("nextPageToken")
|
| 447 |
|
|
|
|
| 457 |
video_stats = {}
|
| 458 |
for i in range(0, len(video_ids), 50):
|
| 459 |
batch_ids = video_ids[i:i+50]
|
| 460 |
+
video_details = youtube.videos().list(id=",".join(batch_ids), part="statistics").execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
for v in video_details.get("items", []):
|
| 462 |
stats = v["statistics"]
|
| 463 |
video_stats[v["id"]] = {
|
|
|
|
| 473 |
channel_subs_raw = {}
|
| 474 |
for i in range(0, len(channel_ids), 50):
|
| 475 |
batch_ids = channel_ids[i:i+50]
|
| 476 |
+
channel_details = youtube.channels().list(id=",".join(batch_ids), part="statistics").execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
for ch in channel_details.get("items", []):
|
| 478 |
sub_count = ch["statistics"].get("subscriberCount", "0")
|
| 479 |
if sub_count:
|
|
|
|
| 482 |
else:
|
| 483 |
channel_subs[ch["id"]] = "비공개"
|
| 484 |
|
|
|
|
| 485 |
videos_data = []
|
| 486 |
for item in all_items:
|
| 487 |
video_id = item["id"]["videoId"]
|
| 488 |
snippet = item["snippet"]
|
| 489 |
stats = video_stats.get(video_id, {})
|
|
|
|
| 490 |
videos_data.append({
|
| 491 |
"video_id": video_id,
|
| 492 |
"title": snippet["title"],
|
|
|
|
| 499 |
"comments": stats.get("comments", 0),
|
| 500 |
})
|
| 501 |
|
| 502 |
+
if save_db_flag:
|
|
|
|
| 503 |
save_to_db(videos_data, channel_subs_raw, keyword, country, language, sort_by)
|
| 504 |
|
| 505 |
+
# JavaScript 정렬 기능 포함 HTML
|
| 506 |
html = f'''
|
| 507 |
<style>
|
| 508 |
.yt-table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
|
| 509 |
+
.yt-table th {{
|
| 510 |
+
background: #ff0000; color: white; padding: 12px 8px;
|
| 511 |
+
text-align: left; position: sticky; top: 0; cursor: pointer;
|
| 512 |
+
user-select: none;
|
| 513 |
+
}}
|
| 514 |
+
.yt-table th:hover {{ background: #cc0000; }}
|
| 515 |
+
.yt-table th::after {{ content: " ⇅"; font-size: 10px; opacity: 0.7; }}
|
| 516 |
+
.yt-table th.sort-asc::after {{ content: " ▲"; opacity: 1; }}
|
| 517 |
+
.yt-table th.sort-desc::after {{ content: " ▼"; opacity: 1; }}
|
| 518 |
.yt-table td {{ padding: 10px 8px; border-bottom: 1px solid #ddd; vertical-align: middle; }}
|
| 519 |
.yt-table tr:hover {{ background: #f5f5f5; }}
|
| 520 |
.yt-table img {{ border-radius: 4px; }}
|
|
|
|
| 524 |
.yt-views {{ font-weight: bold; color: #065fd4; }}
|
| 525 |
.yt-subs {{ color: #606060; }}
|
| 526 |
.yt-date {{ color: #888; font-size: 12px; }}
|
|
|
|
| 527 |
</style>
|
| 528 |
+
|
| 529 |
+
<p><b>총 {len(all_items)}개 결과</b> | 검색어: "{keyword}" | 국가: {country} | 언어: {language} | 정렬: {sort_by}</p>
|
| 530 |
+
<p style="color: #666; font-size: 12px;">💡 테이블 헤더 클릭시 해당 컬럼으로 정렬됩니다</p>
|
| 531 |
+
|
| 532 |
<div style="max-height: 800px; overflow-y: auto;">
|
| 533 |
+
<table class="yt-table" id="resultTable">
|
| 534 |
<thead>
|
| 535 |
<tr>
|
| 536 |
+
<th data-type="number" style="width:50px;">순위</th>
|
| 537 |
+
<th data-type="none" style="width:120px;">썸네일</th>
|
| 538 |
+
<th data-type="string">제목</th>
|
| 539 |
+
<th data-type="string" style="width:150px;">채널</th>
|
| 540 |
+
<th data-type="number" style="width:80px;">구독자</th>
|
| 541 |
+
<th data-type="number" style="width:80px;">조회수</th>
|
| 542 |
+
<th data-type="number" style="width:60px;">좋아요</th>
|
| 543 |
+
<th data-type="number" style="width:60px;">댓글</th>
|
| 544 |
+
<th data-type="date" style="width:90px;">게시일</th>
|
| 545 |
</tr>
|
| 546 |
</thead>
|
| 547 |
<tbody>
|
|
|
|
| 553 |
channel_id = snippet["channelId"]
|
| 554 |
|
| 555 |
title = snippet["title"]
|
| 556 |
+
title_display = title[:50] + "..." if len(title) > 50 else title
|
|
|
|
| 557 |
channel = snippet["channelTitle"]
|
| 558 |
+
channel_display = channel[:15] + "..." if len(channel) > 15 else channel
|
|
|
|
| 559 |
thumbnail = snippet["thumbnails"]["default"]["url"]
|
| 560 |
published = snippet["publishedAt"][:10]
|
| 561 |
url = f"https://youtube.com/watch?v={video_id}"
|
| 562 |
channel_url = f"https://youtube.com/channel/{channel_id}"
|
| 563 |
|
| 564 |
stats = video_stats.get(video_id, {})
|
| 565 |
+
views = stats.get("views", 0)
|
| 566 |
+
views_fmt = stats.get("views_fmt", "N/A")
|
| 567 |
+
likes = stats.get("likes", 0)
|
| 568 |
+
likes_fmt = stats.get("likes_fmt", "N/A")
|
| 569 |
+
comments = stats.get("comments", 0)
|
| 570 |
+
comments_fmt = stats.get("comments_fmt", "N/A")
|
| 571 |
+
subs_raw = channel_subs_raw.get(channel_id, 0)
|
| 572 |
+
subs_fmt = channel_subs.get(channel_id, "N/A")
|
| 573 |
|
| 574 |
if i == 1:
|
| 575 |
rank_style = "color: #FFD700; font-size: 18px;"
|
|
|
|
| 582 |
|
| 583 |
html += f'''
|
| 584 |
<tr>
|
| 585 |
+
<td class="yt-rank" style="{rank_style}" data-value="{i}">{i}</td>
|
| 586 |
<td><a href="{url}" target="_blank"><img src="{thumbnail}" width="100"></a></td>
|
| 587 |
+
<td data-value="{title}"><a href="{url}" target="_blank">{title_display}</a></td>
|
| 588 |
+
<td data-value="{channel}"><a href="{channel_url}" target="_blank">{channel_display}</a></td>
|
| 589 |
+
<td class="yt-subs" data-value="{subs_raw}">{subs_fmt}</td>
|
| 590 |
+
<td class="yt-views" data-value="{views}">{views_fmt}</td>
|
| 591 |
+
<td data-value="{likes}">{likes_fmt}</td>
|
| 592 |
+
<td data-value="{comments}">{comments_fmt}</td>
|
| 593 |
+
<td class="yt-date" data-value="{published}">{published}</td>
|
| 594 |
</tr>
|
| 595 |
'''
|
| 596 |
|
| 597 |
+
html += '''
|
| 598 |
+
</tbody>
|
| 599 |
+
</table>
|
| 600 |
+
</div>
|
| 601 |
+
|
| 602 |
+
<script>
|
| 603 |
+
(function() {
|
| 604 |
+
const table = document.getElementById('resultTable');
|
| 605 |
+
if (!table) return;
|
| 606 |
+
|
| 607 |
+
const headers = table.querySelectorAll('th');
|
| 608 |
+
let currentSort = { col: -1, asc: true };
|
| 609 |
+
|
| 610 |
+
headers.forEach((header, index) => {
|
| 611 |
+
if (header.dataset.type === 'none') return;
|
| 612 |
+
|
| 613 |
+
header.addEventListener('click', () => {
|
| 614 |
+
const tbody = table.querySelector('tbody');
|
| 615 |
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
| 616 |
+
const type = header.dataset.type;
|
| 617 |
+
|
| 618 |
+
// 정렬 방향 토글
|
| 619 |
+
if (currentSort.col === index) {
|
| 620 |
+
currentSort.asc = !currentSort.asc;
|
| 621 |
+
} else {
|
| 622 |
+
currentSort.col = index;
|
| 623 |
+
currentSort.asc = true;
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
// 헤더 스타일 업데이트
|
| 627 |
+
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
| 628 |
+
header.classList.add(currentSort.asc ? 'sort-asc' : 'sort-desc');
|
| 629 |
+
|
| 630 |
+
// 정렬
|
| 631 |
+
rows.sort((a, b) => {
|
| 632 |
+
const aVal = a.cells[index].dataset.value || a.cells[index].textContent;
|
| 633 |
+
const bVal = b.cells[index].dataset.value || b.cells[index].textContent;
|
| 634 |
+
|
| 635 |
+
let result = 0;
|
| 636 |
+
if (type === 'number') {
|
| 637 |
+
result = parseFloat(aVal) - parseFloat(bVal);
|
| 638 |
+
} else if (type === 'date') {
|
| 639 |
+
result = new Date(aVal) - new Date(bVal);
|
| 640 |
+
} else {
|
| 641 |
+
result = aVal.localeCompare(bVal, 'ko');
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
return currentSort.asc ? result : -result;
|
| 645 |
+
});
|
| 646 |
+
|
| 647 |
+
// 재배치
|
| 648 |
+
rows.forEach(row => tbody.appendChild(row));
|
| 649 |
+
});
|
| 650 |
+
});
|
| 651 |
+
})();
|
| 652 |
+
</script>
|
| 653 |
+
'''
|
| 654 |
|
|
|
|
| 655 |
db_stats = get_db_stats()
|
| 656 |
+
stats_html = f"📊 DB: 영상 {db_stats['videos']}개 | 기록 {db_stats['stats_records']}건 | 채널 {db_stats['channels']}개 | 검색 {db_stats['searches']}회"
|
| 657 |
|
| 658 |
return html, stats_html
|
| 659 |
|
| 660 |
def show_trending_alerts():
|
|
|
|
| 661 |
alerts = get_trending_alerts()
|
|
|
|
| 662 |
if not alerts:
|
| 663 |
+
return "<p>급상승 알림이 없습니다. 데이터를 더 수집해주세요.</p>"
|
| 664 |
|
| 665 |
html = '''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
<h3>🔥 급상승 알림 (조회수 20% 이상 증가)</h3>
|
| 667 |
+
<table style="width:100%; border-collapse: collapse;">
|
| 668 |
+
<thead><tr style="background: #ff6b6b; color: white;">
|
| 669 |
+
<th style="padding:10px;">영상</th><th>채널</th><th>이전</th><th>현재</th><th>증가율</th><th>감지시간</th>
|
| 670 |
+
</tr></thead><tbody>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
'''
|
|
|
|
| 672 |
for alert in alerts:
|
| 673 |
+
video_id, title, channel, _, old_val, new_val, change_pct, detected = alert
|
| 674 |
title = title[:30] + "..." if len(title) > 30 else title
|
| 675 |
url = f"https://youtube.com/watch?v={video_id}"
|
| 676 |
+
html += f'''<tr style="border-bottom:1px solid #ddd;">
|
| 677 |
+
<td style="padding:8px;"><a href="{url}" target="_blank">{title}</a></td>
|
| 678 |
+
<td>{channel}</td><td>{format_count(old_val)}</td><td>{format_count(new_val)}</td>
|
| 679 |
+
<td style="color:red;font-weight:bold;">+{change_pct:.1f}%</td>
|
| 680 |
+
<td>{detected[:16].replace("T"," ")}</td>
|
| 681 |
+
</tr>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
html += '</tbody></table>'
|
| 683 |
return html
|
| 684 |
|
| 685 |
def show_top_growing():
|
|
|
|
| 686 |
results = get_top_growing_videos(24)
|
|
|
|
| 687 |
if not results:
|
| 688 |
+
return "<p>최근 24시간 내 급성장 데이터가 없습니다.</p>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
|
| 690 |
+
html = '<h3>📈 24시간 급성장 TOP 10</h3><div style="display:flex;flex-wrap:wrap;gap:15px;">'
|
| 691 |
+
for i, (video_id, title, channel, thumbnail, min_v, max_v, growth) in enumerate(results, 1):
|
| 692 |
title = title[:25] + "..." if len(title) > 25 else title
|
| 693 |
url = f"https://youtube.com/watch?v={video_id}"
|
| 694 |
+
html += f'''<div style="width:180px;border:1px solid #ddd;border-radius:8px;padding:10px;">
|
| 695 |
+
<a href="{url}" target="_blank"><img src="{thumbnail}" style="width:100%;border-radius:5px;"></a>
|
| 696 |
+
<p style="margin:5px 0;font-size:12px;"><b>{i}.</b> {title}</p>
|
| 697 |
+
<p style="margin:0;font-size:11px;color:#666;">{channel}</p>
|
| 698 |
+
<p style="margin:5px 0;font-size:14px;color:#ff0000;font-weight:bold;">📈 +{growth:.1f}%</p>
|
| 699 |
+
</div>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
html += '</div>'
|
| 701 |
return html
|
| 702 |
|
| 703 |
def show_search_history():
|
|
|
|
| 704 |
history = get_search_history()
|
|
|
|
| 705 |
if not history:
|
| 706 |
return "<p>검색 기록이 없습니다.</p>"
|
| 707 |
|
| 708 |
+
html = '''<h3>🕐 최근 검색 기록</h3>
|
| 709 |
+
<table style="width:100%;border-collapse:collapse;">
|
| 710 |
+
<thead><tr style="background:#333;color:white;">
|
| 711 |
+
<th style="padding:10px;">검색어</th><th>국가</th><th>언어</th><th>정렬</th><th>결과수</th><th>시간</th>
|
| 712 |
+
</tr></thead><tbody>'''
|
| 713 |
+
for kw, country, lang, sort_by, count, searched_at in history:
|
| 714 |
+
html += f'''<tr style="border-bottom:1px solid #ddd;">
|
| 715 |
+
<td style="padding:8px;"><b>{kw}</b></td><td>{country}</td><td>{lang}</td>
|
| 716 |
+
<td>{sort_by}</td><td>{count}</td><td>{searched_at[:16].replace("T"," ")}</td>
|
| 717 |
+
</tr>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
html += '</tbody></table>'
|
| 719 |
return html
|
| 720 |
|
| 721 |
+
# 초기 트렌딩
|
| 722 |
+
initial_trending = get_real_trending_keywords("KR", "ko")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
|
| 724 |
with gr.Blocks() as demo:
|
| 725 |
gr.Markdown("## YouTube 인기 영상 검색기 + 트렌드 분석")
|
| 726 |
|
|
|
|
| 727 |
db_stats_display = gr.Markdown("📊 DB 통계 로딩중...")
|
| 728 |
|
| 729 |
with gr.Tabs():
|
|
|
|
| 730 |
with gr.Tab("���� 검색"):
|
| 731 |
+
gr.Markdown("### 🔥 실시간 인기 검색어 (YouTube 트렌딩 기반)")
|
| 732 |
+
trending_radio = gr.Radio(choices=initial_trending, label="클릭하면 검색창에 자동 입력", interactive=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
|
| 734 |
with gr.Row():
|
| 735 |
keyword = gr.Textbox(label="검색어", placeholder="검색어 입력...", scale=3)
|
|
|
|
| 738 |
|
| 739 |
with gr.Row():
|
| 740 |
country = gr.Dropdown(list(COUNTRIES.keys()), value="한국", label="국가 (인구순)")
|
| 741 |
+
language = gr.Dropdown(list(LANGUAGES.keys()), value="자동 (국가 기반)", label="언어")
|
| 742 |
sort_by = gr.Dropdown(list(SORT_OPTIONS.keys()), value="조회수 순", label="정렬")
|
| 743 |
date_filter = gr.Dropdown(list(DATE_OPTIONS.keys()), value="전체 기간", label="기간")
|
| 744 |
max_results = gr.Slider(10, 300, value=50, step=10, label="결과 수")
|
| 745 |
|
| 746 |
search_output = gr.HTML()
|
| 747 |
|
|
|
|
| 748 |
with gr.Tab("🔥 급상승"):
|
| 749 |
+
gr.Button("🔄 새로고침", variant="secondary").click(show_trending_alerts, outputs=gr.HTML())
|
| 750 |
alerts_output = gr.HTML()
|
| 751 |
|
|
|
|
| 752 |
with gr.Tab("📈 급성장 TOP"):
|
| 753 |
+
gr.Button("🔄 새로고침", variant="secondary").click(show_top_growing, outputs=gr.HTML())
|
| 754 |
growing_output = gr.HTML()
|
| 755 |
|
|
|
|
| 756 |
with gr.Tab("🕐 검색 기록"):
|
| 757 |
+
gr.Button("🔄 새로고침", variant="secondary").click(show_search_history, outputs=gr.HTML())
|
| 758 |
history_output = gr.HTML()
|
| 759 |
|
|
|
|
| 760 |
trending_radio.change(use_trending_keyword, trending_radio, keyword)
|
| 761 |
country.change(update_trending, country, trending_radio)
|
| 762 |
+
btn.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [search_output, db_stats_display])
|
| 763 |
+
keyword.submit(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [search_output, db_stats_display])
|
| 764 |
+
refresh_btn.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [search_output, db_stats_display])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
|
| 766 |
demo.launch()
|