seawolf2357 commited on
Commit
2125c39
·
verified ·
1 Parent(s): 17e78e6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +291 -340
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 json
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
- # 국가 코드 - 인구 상위 40개
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
- c.execute('''
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
- INSERT INTO channel_stats (channel_id, subscribers, recorded_at)
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
- return {
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
- region = COUNTRIES.get(country, "")
405
- keywords = get_trending_keywords(region if region else "US")
 
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, save_db=True):
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
- if COUNTRIES[country]:
427
- search_params["regionCode"] = COUNTRIES[country]
428
- if LANGUAGES[language]:
429
- search_params["relevanceLanguage"] = LANGUAGES[language]
 
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
- # DB에 저장
522
- if save_db:
523
  save_to_db(videos_data, channel_subs_raw, keyword, country, language, sort_by)
524
 
525
- # HTML 테이블 생성
526
  html = f'''
527
  <style>
528
  .yt-table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
529
- .yt-table th {{ background: #ff0000; color: white; padding: 12px 8px; text-align: left; position: sticky; top: 0; }}
 
 
 
 
 
 
 
 
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
- <p><b>총 {len(all_items)}개 결과</b> | 검색어: "{keyword}" | 정렬: {sort_by} | 💾 DB 저장 완료</p>
 
 
 
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("views_fmt", "N/A")
578
- likes = stats.get("likes_fmt", "N/A")
579
- comments = stats.get("comments_fmt", "N/A")
580
- subs = channel_subs.get(channel_id, "N/A")
 
 
 
 
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">{title}</a></td>
596
- <td><a href="{channel_url}" target="_blank">{channel}</a></td>
597
- <td class="yt-subs">{subs}</td>
598
- <td class="yt-views">{views}</td>
599
- <td>{likes}</td>
600
- <td>{comments}</td>
601
- <td class="yt-date">{published}</td>
602
  </tr>
603
  '''
604
 
605
- html += '</tbody></table></div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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']}회 | 알림 {db_stats['alerts']}건"
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>아직 급상승 알림이 없습니다. 데이터를 더 수집해주세요.</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 class="alert-table">
629
- <thead>
630
- <tr>
631
- <th>영상</th>
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, alert_type, old_val, new_val, change_pct, detected = alert
644
  title = title[:30] + "..." if len(title) > 30 else title
645
  url = f"https://youtube.com/watch?v={video_id}"
646
- detected_fmt = detected[:16].replace("T", " ")
647
-
648
- html += f'''
649
- <tr>
650
- <td><a href="{url}" target="_blank">{title}</a></td>
651
- <td>{channel}</td>
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시간 내 급성장 영상 데이터가 없습니다.</p>"
668
-
669
- html = '''
670
- <h3>📈 24시간 급성장 TOP 10</h3>
671
- <div style="display: flex; flex-wrap: wrap; gap: 15px;">
672
- '''
673
 
674
- for i, (video_id, title, channel, thumbnail, min_views, max_views, growth) in enumerate(results, 1):
 
675
  title = title[:25] + "..." if len(title) > 25 else title
676
  url = f"https://youtube.com/watch?v={video_id}"
677
-
678
- html += f'''
679
- <div style="width: 180px; border: 1px solid #ddd; border-radius: 8px; padding: 10px;">
680
- <a href="{url}" target="_blank">
681
- <img src="{thumbnail}" style="width: 100%; border-radius: 5px;">
682
- </a>
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
- <h3>🕐 최근 검색 기록</h3>
702
- <table style="width:100%; border-collapse: collapse;">
703
- <thead>
704
- <tr style="background: #333; color: white;">
705
- <th style="padding: 10px;">검색어</th>
706
- <th style="padding: 10px;">국가</th>
707
- <th style="padding: 10px;">언어</th>
708
- <th style="padding: 10px;">정렬</th>
709
- <th style="padding: 10px;">결과 수</th>
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
- def refresh_all():
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="전체", label="언어 (사용인구순)")
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
- refresh_alerts_btn = gr.Button("🔄 새로고침", variant="secondary")
778
  alerts_output = gr.HTML()
779
 
780
- # 탭 3: 급성장
781
  with gr.Tab("📈 급성장 TOP"):
782
- refresh_growing_btn = gr.Button("🔄 새로고침", variant="secondary")
783
  growing_output = gr.HTML()
784
 
785
- # 탭 4: 검색 기록
786
  with gr.Tab("🕐 검색 기록"):
787
- refresh_history_btn = gr.Button("🔄 새로고침", variant="secondary")
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
- btn.click(
795
- search_videos,
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()