seawolf2357 commited on
Commit
0cd2b77
·
verified ·
1 Parent(s): 2125c39

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +672 -550
app.py CHANGED
@@ -4,209 +4,310 @@ 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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  # DB 초기화
12
  def init_db():
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,
19
- title TEXT,
20
- channel_id TEXT,
21
- channel_name TEXT,
22
- thumbnail TEXT,
23
- published_at TEXT,
24
- first_seen TEXT
25
- )
26
- ''')
27
 
28
- c.execute('''
29
- CREATE TABLE IF NOT EXISTS video_stats (
30
- id INTEGER PRIMARY KEY AUTOINCREMENT,
31
- video_id TEXT,
32
- views INTEGER,
33
- likes INTEGER,
34
- comments INTEGER,
35
- recorded_at TEXT,
36
- FOREIGN KEY (video_id) REFERENCES videos(video_id)
37
- )
38
- ''')
39
 
40
- c.execute('''
41
- CREATE TABLE IF NOT EXISTS channels (
42
- channel_id TEXT PRIMARY KEY,
43
- channel_name TEXT,
44
- first_seen TEXT
45
- )
46
- ''')
47
 
48
- c.execute('''
49
- CREATE TABLE IF NOT EXISTS channel_stats (
50
- id INTEGER PRIMARY KEY AUTOINCREMENT,
51
- channel_id TEXT,
52
- subscribers INTEGER,
53
- recorded_at TEXT,
54
- FOREIGN KEY (channel_id) REFERENCES channels(channel_id)
55
- )
56
- ''')
57
 
58
- c.execute('''
59
- CREATE TABLE IF NOT EXISTS search_history (
60
- id INTEGER PRIMARY KEY AUTOINCREMENT,
61
- keyword TEXT,
62
- country TEXT,
63
- language TEXT,
64
- sort_by TEXT,
65
- results_count INTEGER,
66
- searched_at TEXT
67
- )
68
- ''')
69
 
70
- c.execute('''
71
- CREATE TABLE IF NOT EXISTS trending_alerts (
72
- id INTEGER PRIMARY KEY AUTOINCREMENT,
73
- video_id TEXT,
74
- alert_type TEXT,
75
- old_value INTEGER,
76
- new_value INTEGER,
77
- change_percent REAL,
78
- detected_at TEXT,
79
- FOREIGN KEY (video_id) REFERENCES videos(video_id)
80
- )
81
- ''')
82
 
83
  conn.commit()
84
  conn.close()
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",
138
- "스페인어": "es",
139
- "아랍어": "ar",
140
- "벵골어": "bn",
141
- "포르투갈어": "pt",
142
- "러시아어": "ru",
143
- "일본어": "ja",
144
- "펀자브어": "pa",
145
- "독일어": "de",
146
- "인도네시아어": "id",
147
- "페르시아어": "fa",
148
- "프랑스어": "fr",
149
- "베트남어": "vi",
150
- "터키어": "tr",
151
- "한국어": "ko",
152
- "타밀어": "ta",
153
- "이탈리아어": "it",
154
- "텔루구어": "te",
155
- "마라티어": "mr",
156
- "우르두어": "ur",
157
- "태국어": "th",
158
- "구자라트어": "gu",
159
- "폴란드어": "pl",
160
- "말라얄람어": "ml",
161
- "칸나다어": "kn",
162
- "우크라이나어": "uk",
163
- "필리핀어": "tl",
164
- "미얀마어": "my",
165
- "네덜란드어": "nl",
166
- "루마니아어": "ro",
167
- "스와힐리어": "sw",
168
- "그리스어": "el",
169
- "체코어": "cs",
170
- "헝가리어": "hu",
171
- "스웨덴어": "sv",
172
- "히브리어": "he",
173
- "핀란드어": "fi",
174
- "노르웨이어": "no",
175
  }
176
 
177
- SORT_OPTIONS = {
178
- "조회수 ": "viewCount",
179
- "최신순": "date",
180
- "관련성 순": "relevance",
181
- "평점 순": "rating",
182
- }
183
-
184
- DATE_OPTIONS = {
185
- "전체 기간": "",
186
- "오늘": "today",
187
- "이번 주": "thisWeek",
188
- "이번 달": "thisMonth",
189
- "올해": "thisYear",
190
- }
191
 
192
  def format_count(count):
193
- if count >= 1000000000:
194
- return f"{count/1000000000:.1f}B"
195
- elif count >= 1000000:
196
- return f"{count/1000000:.1f}M"
197
- elif count >= 1000:
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 = []
@@ -217,236 +318,122 @@ def get_real_trending_keywords(region_code="KR", language="ko"):
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 = ?
295
- ORDER BY recorded_at DESC LIMIT 1
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:
307
- c.execute('''
308
- INSERT INTO trending_alerts (video_id, alert_type, old_value, new_value, change_percent, detected_at)
309
- VALUES (?, ?, ?, ?, ?, ?)
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,
354
- ((MAX(vs.views) - MIN(vs.views)) * 100.0 / MIN(vs.views)) as growth_rate
355
- FROM videos v
356
- JOIN video_stats vs ON v.video_id = vs.video_id
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
 
396
  max_results = int(max_results)
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",
412
- "type": "video",
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()
424
- if date_filter == "오늘":
425
- after = now - timedelta(days=1)
426
- elif date_filter == "이번 주":
427
- after = now - timedelta(weeks=1)
428
- elif date_filter == "이번 달":
429
- after = now - timedelta(days=30)
430
- elif date_filter == "올해":
431
- after = now - timedelta(days=365)
432
- search_params["publishedAfter"] = after.strftime("%Y-%m-%dT%H:%M:%SZ")
433
 
 
434
  while len(all_items) < max_results:
435
- search_params["maxResults"] = min(50, max_results - len(all_items))
436
- if next_page_token:
437
- search_params["pageToken"] = next_page_token
438
-
439
- response = youtube.search().list(**search_params).execute()
440
- items = response.get("items", [])
441
 
442
- if not items:
443
- break
 
444
 
445
  all_items.extend(items)
446
- next_page_token = response.get("nextPageToken")
447
-
448
- if not next_page_token:
449
- break
450
 
451
  if not all_items:
452
  return "검색 결과가 없습니다.", ""
@@ -454,144 +441,138 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
454
  video_ids = [item["id"]["videoId"] for item in all_items]
455
  channel_ids = list(set([item["snippet"]["channelId"] for item in all_items]))
456
 
 
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"]] = {
464
- "views": int(stats.get("viewCount", 0)),
465
- "views_fmt": format_count(int(stats.get("viewCount", 0))),
466
- "likes": int(stats.get("likeCount", 0)),
467
- "likes_fmt": format_count(int(stats.get("likeCount", 0))),
468
- "comments": int(stats.get("commentCount", 0)),
469
- "comments_fmt": format_count(int(stats.get("commentCount", 0))),
470
  }
471
 
 
472
  channel_subs = {}
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:
480
- channel_subs_raw[ch["id"]] = int(sub_count)
481
- channel_subs[ch["id"]] = format_count(int(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"],
493
- "channel_id": snippet["channelId"],
494
- "channel_name": snippet["channelTitle"],
495
- "thumbnail": snippet["thumbnails"]["default"]["url"],
496
- "published_at": snippet["publishedAt"],
497
- "views": stats.get("views", 0),
498
- "likes": stats.get("likes", 0),
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; }}
521
- .yt-table a {{ color: #1a0dab; text-decoration: none; }}
522
- .yt-table a:hover {{ text-decoration: underline; }}
523
- .yt-rank {{ font-weight: bold; color: #ff0000; text-align: center; }}
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>
548
  '''
549
 
550
- for i, item in enumerate(all_items, 1):
551
- video_id = item["id"]["videoId"]
552
- snippet = item["snippet"]
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;"
576
- elif i == 2:
577
- rank_style = "color: #C0C0C0; font-size: 16px;"
578
- elif i == 3:
579
- rank_style = "color: #CD7F32; font-size: 16px;"
580
- else:
581
- rank_style = ""
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 += '''
@@ -600,167 +581,308 @@ def search_videos(keyword, country, language, sort_by, date_filter, max_results,
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)
736
  btn = gr.Button("🔍 검색", variant="primary", scale=1)
737
- refresh_btn = gr.Button("🔄 갱신", scale=1)
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()
 
4
  import sqlite3
5
  from datetime import datetime, timedelta
6
  import re
7
+ import anthropic
8
 
9
  API_KEY = os.getenv("YOUTUBE_API_KEY")
10
+ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
11
+
12
  youtube = build("youtube", "v3", developerKey=API_KEY)
13
+ claude_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) if ANTHROPIC_API_KEY else None
14
+
15
+ # ============================================
16
+ # 🎨 Comic Classic Theme CSS
17
+ # ============================================
18
+ css = """
19
+ @import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
20
+
21
+ .gradio-container {
22
+ background-color: #FEF9C3 !important;
23
+ background-image: radial-gradient(#1F2937 1px, transparent 1px) !important;
24
+ background-size: 20px 20px !important;
25
+ min-height: 100vh !important;
26
+ font-family: 'Comic Neue', cursive, sans-serif !important;
27
+ }
28
+
29
+ footer, .footer, .gradio-container footer, .built-with, [class*="footer"] {
30
+ display: none !important;
31
+ }
32
+
33
+ .header-text h1 {
34
+ font-family: 'Bangers', cursive !important;
35
+ color: #1F2937 !important;
36
+ font-size: 2.8rem !important;
37
+ text-align: center !important;
38
+ text-shadow: 4px 4px 0px #FACC15, 6px 6px 0px #1F2937 !important;
39
+ letter-spacing: 3px !important;
40
+ -webkit-text-stroke: 1px #1F2937 !important;
41
+ }
42
+
43
+ .gr-panel, .gr-box, .gr-form, .block, .gr-group {
44
+ background: #FFFFFF !important;
45
+ border: 3px solid #1F2937 !important;
46
+ border-radius: 8px !important;
47
+ box-shadow: 6px 6px 0px #1F2937 !important;
48
+ }
49
+
50
+ .gr-button-primary, button.primary {
51
+ background: #3B82F6 !important;
52
+ border: 3px solid #1F2937 !important;
53
+ border-radius: 8px !important;
54
+ color: #FFFFFF !important;
55
+ font-family: 'Bangers', cursive !important;
56
+ font-size: 1.2rem !important;
57
+ letter-spacing: 2px !important;
58
+ box-shadow: 5px 5px 0px #1F2937 !important;
59
+ text-shadow: 1px 1px 0px #1F2937 !important;
60
+ }
61
+
62
+ .gr-button-primary:hover { background: #2563EB !important; transform: translate(-2px, -2px) !important; }
63
+ .gr-button-primary:active { transform: translate(3px, 3px) !important; box-shadow: 2px 2px 0px #1F2937 !important; }
64
+
65
+ .gr-button-secondary, button.secondary {
66
+ background: #EF4444 !important;
67
+ border: 3px solid #1F2937 !important;
68
+ color: #FFFFFF !important;
69
+ font-family: 'Bangers', cursive !important;
70
+ box-shadow: 4px 4px 0px #1F2937 !important;
71
+ }
72
+
73
+ textarea, input[type="text"], input[type="number"] {
74
+ background: #FFFFFF !important;
75
+ border: 3px solid #1F2937 !important;
76
+ border-radius: 8px !important;
77
+ color: #1F2937 !important;
78
+ font-family: 'Comic Neue', cursive !important;
79
+ font-weight: 700 !important;
80
+ }
81
+
82
+ label, .gr-input-label, .gr-block-label {
83
+ color: #1F2937 !important;
84
+ font-family: 'Comic Neue', cursive !important;
85
+ font-weight: 700 !important;
86
+ }
87
+
88
+ .gr-accordion {
89
+ background: #FACC15 !important;
90
+ border: 3px solid #1F2937 !important;
91
+ box-shadow: 4px 4px 0px #1F2937 !important;
92
+ }
93
+
94
+ ::-webkit-scrollbar { width: 12px; }
95
+ ::-webkit-scrollbar-track { background: #FEF9C3; border: 2px solid #1F2937; }
96
+ ::-webkit-scrollbar-thumb { background: #3B82F6; border: 2px solid #1F2937; }
97
+ ::-webkit-scrollbar-thumb:hover { background: #EF4444; }
98
+
99
+ ::selection { background: #FACC15; color: #1F2937; }
100
+ """
101
 
102
  # DB 초기화
103
  def init_db():
104
  conn = sqlite3.connect("youtube_data.db")
105
  c = conn.cursor()
106
 
107
+ c.execute('''CREATE TABLE IF NOT EXISTS videos (
108
+ video_id TEXT PRIMARY KEY, title TEXT, channel_id TEXT, channel_name TEXT,
109
+ thumbnail TEXT, published_at TEXT, first_seen TEXT
110
+ )''')
 
 
 
 
 
 
 
111
 
112
+ c.execute('''CREATE TABLE IF NOT EXISTS video_stats (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT, views INTEGER,
114
+ likes INTEGER, comments INTEGER, recorded_at TEXT
115
+ )''')
 
 
 
 
 
 
 
116
 
117
+ c.execute('''CREATE TABLE IF NOT EXISTS channels (
118
+ channel_id TEXT PRIMARY KEY, channel_name TEXT, first_seen TEXT
119
+ )''')
 
 
 
 
120
 
121
+ c.execute('''CREATE TABLE IF NOT EXISTS channel_stats (
122
+ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT, subscribers INTEGER, recorded_at TEXT
123
+ )''')
 
 
 
 
 
 
124
 
125
+ c.execute('''CREATE TABLE IF NOT EXISTS search_history (
126
+ id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT, country TEXT,
127
+ language TEXT, sort_by TEXT, results_count INTEGER, searched_at TEXT
128
+ )''')
 
 
 
 
 
 
 
129
 
130
+ c.execute('''CREATE TABLE IF NOT EXISTS trending_alerts (
131
+ id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT, alert_type TEXT,
132
+ old_value INTEGER, new_value INTEGER, change_percent REAL, detected_at TEXT
133
+ )''')
 
 
 
 
 
 
 
 
134
 
135
  conn.commit()
136
  conn.close()
137
 
138
  init_db()
139
 
140
+ # 국가 코드
141
  COUNTRIES = {
142
+ "전세계": ("", ""), "중국": ("CN", "zh"), "인도": ("IN", "hi"), "미국": ("US", "en"),
143
+ "인도네시아": ("ID", "id"), "파키스탄": ("PK", "ur"), "브라질": ("BR", "pt"),
144
+ "나이지리아": ("NG", "en"), "방글라데시": ("BD", "bn"), "러시아": ("RU", "ru"),
145
+ "멕시코": ("MX", "es"), "일본": ("JP", "ja"), "에티오피아": ("ET", "am"),
146
+ "필리핀": ("PH", "tl"), "이집트": ("EG", "ar"), "베트남": ("VN", "vi"),
147
+ "콩고민주공화국": ("CD", "fr"), "터키": ("TR", "tr"), "이란": ("IR", "fa"),
148
+ "독일": ("DE", "de"), "태국": ("TH", "th"), "영국": ("GB", "en"),
149
+ "프랑스": ("FR", "fr"), "이탈리아": ("IT", "it"), "남아공": ("ZA", "en"),
150
+ "탄자니아": ("TZ", "sw"), "미얀마": ("MM", "my"), "한국": ("KR", "ko"),
151
+ "콜롬비아": ("CO", "es"), "케냐": ("KE", "sw"), "스페인": ("ES", "es"),
152
+ "아르헨티나": ("AR", "es"), "알제리": ("DZ", "ar"), "우크라이나": ("UA", "uk"),
153
+ "이라크": ("IQ", "ar"), "캐나다": ("CA", "en"), "폴란드": ("PL", "pl"),
154
+ "모로코": ("MA", "ar"), "사우디": ("SA", "ar"), "페루": ("PE", "es"), "말레이시아": ("MY", "ms"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  }
156
 
157
  LANGUAGES = {
158
+ "자동 (국가 기반)": "", "영어": "en", "중국어": "zh", "힌디어": "hi",
159
+ "스페인어": "es", "아랍어": "ar", "벵골어": "bn", "포르투갈어": "pt",
160
+ "러시아어": "ru", "일본어": "ja", "펀자브어": "pa", "독일어": "de",
161
+ "인도네시아어": "id", "페르시아어": "fa", "프랑스어": "fr", "베트남어": "vi",
162
+ "터키어": "tr", "한국어": "ko", "타밀어": "ta", "이탈리아어": "it",
163
+ "텔루구어": "te", "마라티어": "mr", "우르두어": "ur", "태국어": "th",
164
+ "구자라트어": "gu", "폴란드어": "pl", "말라얄람어": "ml", "칸나다어": "kn",
165
+ "우크라이나어": "uk", "필리핀어": "tl", "미얀마어": "my", "네덜란드어": "nl",
166
+ "루마니아어": "ro", "스와힐리어": "sw", "그리스어": "el", "체코어": "cs",
167
+ "헝가리어": "hu", "스웨덴어": "sv", "히브리어": "he", "핀란드어": "fi", "노르웨이어": "no",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
 
170
+ SORT_OPTIONS = {"조회수 순": "viewCount", "최신순": "date", "관련성 순": "relevance", "평점 순": "rating"}
171
+ DATE_OPTIONS = {"전체 기간": "", "오늘": "today", "이번 주": "thisWeek", "이번 달": "thisMonth", "올해": "thisYear"}
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  def format_count(count):
174
+ if count >= 1000000000: return f"{count/1000000000:.1f}B"
175
+ elif count >= 1000000: return f"{count/1000000:.1f}M"
176
+ elif count >= 1000: return f"{count/1000:.1f}K"
 
 
 
177
  return str(count)
178
 
179
+ def get_ai_pick_rating(videos_data):
180
+ """Claude API로 AI Pick 등급 산정"""
181
+ if not claude_client or not videos_data:
182
+ # API 없으면 자체 알고리즘으로 계산
183
+ return calculate_local_rating(videos_data)
184
+
185
+ try:
186
+ # 상위 100개만 분석 (API 비용 절감)
187
+ sample = videos_data[:100]
188
+
189
+ video_info = "\n".join([
190
+ f"#{i+1}. 제목: {v['title'][:50]}, 채널: {v['channel_name']}, "
191
+ f"조회수: {v['views']}, 좋아요: {v['likes']}, 댓글: {v['comments']}, "
192
+ f"구독자: {v.get('subs', 0)}"
193
+ for i, v in enumerate(sample)
194
+ ])
195
+
196
+ response = claude_client.messages.create(
197
+ model="claude-sonnet-4-20250514",
198
+ max_tokens=2000,
199
+ messages=[{
200
+ "role": "user",
201
+ "content": f"""다음 YouTube 영상 목록을 분석하여 각 영상의 종합 품질 등급을 매겨주세요.
202
+
203
+ 평가 기준:
204
+ - 조회수 대비 좋아요/댓글 비율 (engagement)
205
+ - 채널 구독자 대비 조회수 (viral potential)
206
+ - 전반적인 인기도와 성장 가능성
207
+
208
+ 등급 (숫자로만 응답):
209
+ 0 = 없음 (평균 이하)
210
+ 1 = ♥ (평균)
211
+ 2 = ⭐ (좋음)
212
+ 3 = ⭐⭐ (매우 좋음)
213
+ 4 = ⭐⭐⭐ (최고)
214
+
215
+ 각 영상 번호와 등급만 쉼표로 구분하여 응답하세요.
216
+ 예시: 1:3,2:2,3:4,4:1,5:0
217
+
218
+ 영상 목록:
219
+ {video_info}
220
+
221
+ 응답 (번호:등급 형식으로만):"""
222
+ }]
223
+ )
224
+
225
+ # 응답 파싱
226
+ ratings = {}
227
+ result_text = response.content[0].text.strip()
228
+ pairs = re.findall(r'(\d+):(\d)', result_text)
229
+ for idx, rating in pairs:
230
+ ratings[int(idx)-1] = int(rating)
231
+
232
+ return ratings
233
+
234
+ except Exception as e:
235
+ print(f"AI Rating Error: {e}")
236
+ return calculate_local_rating(videos_data)
237
+
238
+ def calculate_local_rating(videos_data):
239
+ """로컬 알고리즘으로 등급 계산"""
240
+ ratings = {}
241
+
242
+ if not videos_data:
243
+ return ratings
244
+
245
+ # 통계 계산
246
+ views_list = [v['views'] for v in videos_data if v['views'] > 0]
247
+ if not views_list:
248
+ return ratings
249
+
250
+ avg_views = sum(views_list) / len(views_list)
251
+ max_views = max(views_list)
252
+
253
+ for i, v in enumerate(videos_data):
254
+ views = v['views']
255
+ likes = v['likes']
256
+ comments = v['comments']
257
+ subs = v.get('subs', 0)
258
+
259
+ # 스코어 계산
260
+ score = 0
261
+
262
+ # 조회수 점수 (0-40)
263
+ if views > 0:
264
+ view_score = min(40, (views / max_views) * 40)
265
+ score += view_score
266
+
267
+ # engagement 점수 (0-30)
268
+ if views > 0:
269
+ engagement = ((likes + comments * 2) / views) * 100
270
+ engagement_score = min(30, engagement * 3)
271
+ score += engagement_score
272
+
273
+ # viral 점수 (구독자 ���비 조회수) (0-30)
274
+ if subs > 0 and views > 0:
275
+ viral = views / subs
276
+ viral_score = min(30, viral * 10)
277
+ score += viral_score
278
+ elif views > avg_views:
279
+ score += 15
280
+
281
+ # 등급 결정
282
+ if score >= 70:
283
+ ratings[i] = 4 # ⭐⭐⭐
284
+ elif score >= 50:
285
+ ratings[i] = 3 # ⭐⭐
286
+ elif score >= 30:
287
+ ratings[i] = 2 # ⭐
288
+ elif score >= 15:
289
+ ratings[i] = 1 # ♥
290
+ else:
291
+ ratings[i] = 0 # 없음
292
+
293
+ return ratings
294
+
295
+ def get_rating_display(rating):
296
+ """등급을 이모지로 변환"""
297
+ displays = {
298
+ 0: "",
299
+ 1: "♥",
300
+ 2: "⭐",
301
+ 3: "⭐⭐",
302
+ 4: "⭐⭐⭐"
303
+ }
304
+ return displays.get(rating, "")
305
+
306
  def get_real_trending_keywords(region_code="KR", language="ko"):
 
307
  try:
 
308
  response = youtube.videos().list(
309
+ part="snippet", chart="mostPopular",
310
+ regionCode=region_code if region_code else "US", maxResults=50
 
 
311
  ).execute()
312
 
313
  keywords = []
 
318
  channel = item["snippet"]["channelTitle"]
319
  tags = item["snippet"].get("tags", [])
320
 
 
321
  for tag in tags[:3]:
322
  tag_clean = tag.strip().lower()
323
  if tag_clean not in seen and 2 <= len(tag_clean) <= 20:
324
  keywords.append(tag)
325
  seen.add(tag_clean)
326
 
327
+ if channel not in seen:
 
328
  keywords.append(channel)
329
  seen.add(channel.lower())
330
 
331
+ words = re.findall(r'[가-힣]{2,10}' if region_code == "KR" else r'[A-Za-z]{3,15}', title)
 
 
 
 
 
332
  for word in words[:2]:
333
+ if word.lower() not in seen and word.lower() not in ['the','and','for','with']:
 
334
  keywords.append(word)
335
+ seen.add(word.lower())
336
 
337
  if len(keywords) >= 30:
338
  break
339
 
340
+ popular = {
341
+ "KR": ["뉴진스","BTS","먹방","브이로그","게임","shorts","드라마","영화","음악","코딩","AI","축구"],
342
+ "US": ["Taylor Swift","NBA","NFL","gaming","vlog","music","AI","coding","shorts","news"],
343
+ "JP": ["アニメ","ゲーム","音楽","vlog","料理","","映画","ASMR","shorts","AI"],
 
344
  }
345
+ for kw in popular.get(region_code, popular["US"]):
 
 
346
  if kw.lower() not in seen:
347
  keywords.append(kw)
348
  seen.add(kw.lower())
349
 
350
+ return keywords[:20] if keywords else ["AI","ChatGPT","뉴진스","BTS","게임","먹방","브이로그","shorts"]
351
+ except:
352
+ return ["AI","ChatGPT","뉴진스","BTS","게임","먹방","브이로그","shorts","음악","축구"]
 
 
 
 
 
 
 
 
 
 
 
353
 
354
  def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
355
  conn = sqlite3.connect("youtube_data.db")
356
  c = conn.cursor()
357
  now = datetime.now().isoformat()
358
 
359
+ c.execute('INSERT INTO search_history (keyword,country,language,sort_by,results_count,searched_at) VALUES (?,?,?,?,?,?)',
360
+ (keyword, country, language, sort_by, len(videos_data), now))
 
 
361
 
362
  for video in videos_data:
363
+ c.execute('INSERT OR IGNORE INTO videos (video_id,title,channel_id,channel_name,thumbnail,published_at,first_seen) VALUES (?,?,?,?,?,?,?)',
364
+ (video['video_id'], video['title'], video['channel_id'], video['channel_name'], video['thumbnail'], video['published_at'], now))
 
 
 
365
 
366
+ c.execute('SELECT views FROM video_stats WHERE video_id=? ORDER BY recorded_at DESC LIMIT 1', (video['video_id'],))
367
+ prev = c.fetchone()
 
 
 
 
368
 
369
+ c.execute('INSERT INTO video_stats (video_id,views,likes,comments,recorded_at) VALUES (?,?,?,?,?)',
370
+ (video['video_id'], video['views'], video['likes'], video['comments'], now))
 
 
371
 
372
+ if prev and prev[0] > 0:
373
+ change = ((video['views'] - prev[0]) / prev[0]) * 100
374
+ if change >= 20:
375
+ c.execute('INSERT INTO trending_alerts (video_id,alert_type,old_value,new_value,change_percent,detected_at) VALUES (?,?,?,?,?,?)',
376
+ (video['video_id'], 'views_surge', prev[0], video['views'], change, now))
377
+
378
+ for ch_id, subs in channels_data.items():
379
+ c.execute('INSERT OR IGNORE INTO channels (channel_id,channel_name,first_seen) VALUES (?,?,?)', (ch_id, '', now))
 
 
 
380
  if isinstance(subs, int):
381
+ c.execute('INSERT INTO channel_stats (channel_id,subscribers,recorded_at) VALUES (?,?,?)', (ch_id, subs, now))
 
382
 
383
  conn.commit()
384
  conn.close()
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  def get_db_stats():
387
  conn = sqlite3.connect("youtube_data.db")
388
  c = conn.cursor()
389
+ stats = {}
390
+ for table, key in [("videos","videos"),("video_stats","stats"),("channels","channels"),("search_history","searches"),("trending_alerts","alerts")]:
391
+ c.execute(f"SELECT COUNT(*) FROM {table}")
392
+ stats[key] = c.fetchone()[0]
 
 
 
 
 
 
393
  conn.close()
394
+ return stats
 
395
 
396
  def update_trending(country):
397
+ region, lang = COUNTRIES.get(country, ("", ""))
398
+ keywords = get_real_trending_keywords(region or "US", lang)
 
399
  return gr.update(choices=keywords, value=None)
400
 
401
+ def use_trending_keyword(kw):
402
+ return kw if kw else ""
403
 
404
+ def search_videos(keyword, country, language, sort_by, date_filter, max_results):
405
  if not keyword or not keyword.strip():
406
+ return "⚠️ 검색어를 입력하세요!", ""
407
 
408
  max_results = int(max_results)
409
  all_items = []
410
+ next_page = None
411
 
 
412
  region_code, default_lang = COUNTRIES.get(country, ("", ""))
413
+ lang_code = default_lang if language == "자동 (국가 기반)" else LANGUAGES.get(language, "")
414
 
415
+ params = {"q": keyword, "part": "snippet", "type": "video", "order": SORT_OPTIONS[sort_by]}
416
+ if region_code: params["regionCode"] = region_code
417
+ if lang_code: params["relevanceLanguage"] = lang_code
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
  if date_filter != "전체 기간":
420
  now = datetime.utcnow()
421
+ deltas = {"오늘": 1, "이번 주": 7, "이번 달": 30, "올해": 365}
422
+ after = now - timedelta(days=deltas.get(date_filter, 0))
423
+ params["publishedAfter"] = after.strftime("%Y-%m-%dT%H:%M:%SZ")
 
 
 
 
 
 
424
 
425
+ # 페이지네이션으로 최대 1000개까지
426
  while len(all_items) < max_results:
427
+ params["maxResults"] = min(50, max_results - len(all_items))
428
+ if next_page: params["pageToken"] = next_page
 
 
 
 
429
 
430
+ resp = youtube.search().list(**params).execute()
431
+ items = resp.get("items", [])
432
+ if not items: break
433
 
434
  all_items.extend(items)
435
+ next_page = resp.get("nextPageToken")
436
+ if not next_page: break
 
 
437
 
438
  if not all_items:
439
  return "검색 결과가 없습니다.", ""
 
441
  video_ids = [item["id"]["videoId"] for item in all_items]
442
  channel_ids = list(set([item["snippet"]["channelId"] for item in all_items]))
443
 
444
+ # 비디오 통계
445
  video_stats = {}
446
  for i in range(0, len(video_ids), 50):
447
+ batch = video_ids[i:i+50]
448
+ details = youtube.videos().list(id=",".join(batch), part="statistics").execute()
449
+ for v in details.get("items", []):
450
+ s = v["statistics"]
451
  video_stats[v["id"]] = {
452
+ "views": int(s.get("viewCount", 0)),
453
+ "likes": int(s.get("likeCount", 0)),
454
+ "comments": int(s.get("commentCount", 0)),
 
 
 
455
  }
456
 
457
+ # 채널 통계
458
  channel_subs = {}
459
  channel_subs_raw = {}
460
  for i in range(0, len(channel_ids), 50):
461
+ batch = channel_ids[i:i+50]
462
+ details = youtube.channels().list(id=",".join(batch), part="statistics").execute()
463
+ for ch in details.get("items", []):
464
+ sub = ch["statistics"].get("subscriberCount", "0")
465
+ if sub:
466
+ channel_subs_raw[ch["id"]] = int(sub)
467
+ channel_subs[ch["id"]] = format_count(int(sub))
468
  else:
469
  channel_subs[ch["id"]] = "비공개"
470
 
471
+ # 데이터 준비
472
  videos_data = []
473
  for item in all_items:
474
+ vid = item["id"]["videoId"]
475
+ snip = item["snippet"]
476
+ st = video_stats.get(vid, {})
477
  videos_data.append({
478
+ "video_id": vid, "title": snip["title"],
479
+ "channel_id": snip["channelId"], "channel_name": snip["channelTitle"],
480
+ "thumbnail": snip["thumbnails"]["default"]["url"],
481
+ "published_at": snip["publishedAt"],
482
+ "views": st.get("views", 0), "likes": st.get("likes", 0),
483
+ "comments": st.get("comments", 0),
484
+ "subs": channel_subs_raw.get(snip["channelId"], 0),
 
 
485
  })
486
 
487
+ # AI Pick 등급 계산
488
+ ai_ratings = get_ai_pick_rating(videos_data)
489
 
490
+ # DB 저장
491
+ save_to_db(videos_data, channel_subs_raw, keyword, country, language, sort_by)
492
+
493
+ # HTML 생성
494
  html = f'''
495
  <style>
496
+ .yt-table {{ width:100%; border-collapse:collapse; font-family:'Comic Neue',cursive; }}
497
  .yt-table th {{
498
+ background:#EF4444; color:white; padding:12px 8px; text-align:left;
499
+ cursor:pointer; user-select:none; border:2px solid #1F2937;
500
+ font-family:'Bangers',cursive; letter-spacing:1px;
501
+ }}
502
+ .yt-table th:hover {{ background:#DC2626; }}
503
+ .yt-table th.sort-asc::after {{ content:" ▲"; }}
504
+ .yt-table th.sort-desc::after {{ content:" ▼"; }}
505
+ .yt-table td {{ padding:10px 8px; border-bottom:2px solid #1F2937; vertical-align:middle; background:#FFFFFF; }}
506
+ .yt-table tr:hover td {{ background:#FEF9C3; }}
507
+ .yt-table img {{ border-radius:4px; border:2px solid #1F2937; }}
508
+ .yt-table a {{ color:#3B82F6; text-decoration:none; font-weight:700; }}
509
+ .yt-table a:hover {{ color:#EF4444; }}
510
+ .yt-rank {{ font-family:'Bangers',cursive; color:#EF4444; text-align:center; font-size:1.1rem; }}
511
+ .yt-views {{ font-weight:700; color:#3B82F6; }}
512
+ .ai-pick {{ color:#FACC15; text-shadow:1px 1px 0 #1F2937; font-size:1.1rem; }}
513
+ .result-header {{
514
+ background:#3B82F6; color:white; padding:15px; border-radius:8px;
515
+ border:3px solid #1F2937; box-shadow:4px 4px 0 #1F2937;
516
+ font-family:'Comic Neue',cursive; font-weight:700; margin-bottom:15px;
517
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  </style>
519
 
520
+ <div class="result-header">
521
+ 🎬 총 <b>{len(all_items)}개</b> 결과 | 🔍 "{keyword}" | 🌍 {country} | 📊 {sort_by}
522
+ <br><span style="font-size:0.9rem;">💡 헤더 클릭 = 정렬 | AI Pick: ♥(평균) ⭐(좋음) ⭐⭐(매우좋음) ⭐⭐⭐(최고)</span>
523
+ </div>
524
+
525
+ <div style="max-height:700px; overflow-y:auto; border:3px solid #1F2937; border-radius:8px;">
526
+ <table class="yt-table" id="ytTable">
527
+ <thead><tr>
528
+ <th data-col="0" data-type="num" style="width:50px;">순위</th>
529
+ <th style="width:100px;">썸네일</th>
530
+ <th data-col="2" data-type="str">제목</th>
531
+ <th data-col="3" data-type="str" style="width:120px;">채널</th>
532
+ <th data-col="4" data-type="num" style="width:70px;">구독자</th>
533
+ <th data-col="5" data-type="num" style="width:70px;">조회수</th>
534
+ <th data-col="6" data-type="num" style="width:55px;">좋아요</th>
535
+ <th data-col="7" data-type="num" style="width:55px;">댓글</th>
536
+ <th data-col="8" data-type="num" style="width:70px;">AI Pick</th>
537
+ <th data-col="9" data-type="str" style="width:85px;">게시일</th>
538
+ </tr></thead>
539
  <tbody>
540
  '''
541
 
542
+ for i, v in enumerate(videos_data):
543
+ vid = v["video_id"]
544
+ title = v["title"][:45] + "..." if len(v["title"]) > 45 else v["title"]
545
+ channel = v["channel_name"][:12] + "..." if len(v["channel_name"]) > 12 else v["channel_name"]
546
+ url = f"https://youtube.com/watch?v={vid}"
547
+ ch_url = f"https://youtube.com/channel/{v['channel_id']}"
548
+ published = v["published_at"][:10]
549
 
550
+ views = v["views"]
551
+ likes = v["likes"]
552
+ comments = v["comments"]
553
+ subs = v["subs"]
 
 
 
 
554
 
555
+ rating = ai_ratings.get(i, 0)
556
+ rating_display = get_rating_display(rating)
 
 
 
 
 
 
 
557
 
558
+ rank_style = ""
559
+ if i == 0: rank_style = "color:#FFD700; font-size:1.3rem;"
560
+ elif i == 1: rank_style = "color:#C0C0C0; font-size:1.2rem;"
561
+ elif i == 2: rank_style = "color:#CD7F32; font-size:1.2rem;"
 
 
 
 
562
 
563
  html += f'''
564
+ <tr>
565
+ <td class="yt-rank" style="{rank_style}" data-v="{i+1}">{i+1}</td>
566
+ <td><a href="{url}" target="_blank"><img src="{v['thumbnail']}" width="90"></a></td>
567
+ <td data-v="{v['title']}"><a href="{url}" target="_blank">{title}</a></td>
568
+ <td data-v="{v['channel_name']}"><a href="{ch_url}" target="_blank">{channel}</a></td>
569
+ <td data-v="{subs}">{format_count(subs)}</td>
570
+ <td class="yt-views" data-v="{views}">{format_count(views)}</td>
571
+ <td data-v="{likes}">{format_count(likes)}</td>
572
+ <td data-v="{comments}">{format_count(comments)}</td>
573
+ <td class="ai-pick" data-v="{rating}">{rating_display}</td>
574
+ <td data-v="{published}">{published}</td>
575
+ </tr>
576
  '''
577
 
578
  html += '''
 
581
  </div>
582
 
583
  <script>
584
+ (function(){
585
+ var table = document.getElementById('ytTable');
586
+ if(!table) return;
587
+ var headers = table.querySelectorAll('th[data-col]');
588
+ var sortCol = -1, sortAsc = true;
589
 
590
+ headers.forEach(function(th){
591
+ th.onclick = function(){
592
+ var col = parseInt(this.dataset.col);
593
+ var type = this.dataset.type;
594
+ var tbody = table.querySelector('tbody');
595
+ var rows = Array.from(tbody.querySelectorAll('tr'));
 
 
 
 
596
 
597
+ if(sortCol === col){ sortAsc = !sortAsc; }
598
+ else { sortCol = col; sortAsc = true; }
 
 
 
 
 
599
 
600
+ headers.forEach(function(h){ h.classList.remove('sort-asc','sort-desc'); });
601
+ this.classList.add(sortAsc ? 'sort-asc' : 'sort-desc');
 
602
 
603
+ rows.sort(function(a,b){
604
+ var aVal = a.cells[col].dataset.v || a.cells[col].textContent;
605
+ var bVal = b.cells[col].dataset.v || b.cells[col].textContent;
606
+ var result = 0;
607
+ if(type === 'num'){
608
+ result = parseFloat(aVal||0) - parseFloat(bVal||0);
 
 
 
 
609
  } else {
610
+ result = (aVal||'').localeCompare(bVal||'','ko');
611
  }
612
+ return sortAsc ? result : -result;
 
613
  });
614
 
615
+ rows.forEach(function(row){ tbody.appendChild(row); });
616
+ };
 
617
  });
618
  })();
619
  </script>
620
  '''
621
 
622
+ stats = get_db_stats()
623
+ stats_html = f"📊 DB: 영상 {stats['videos']} | 기록 {stats['stats']} | 채널 {stats['channels']} | 검색 {stats['searches']}"
624
 
625
  return html, stats_html
626
 
627
  def show_trending_alerts():
628
+ """급상승 알림 + 실시간 데이터 갱신"""
629
+ conn = sqlite3.connect("youtube_data.db")
630
+ c = conn.cursor()
631
+
632
+ # 최근 급상승 알림
633
+ c.execute('''
634
+ SELECT ta.video_id, v.title, v.channel_name, ta.old_value, ta.new_value, ta.change_percent, ta.detected_at
635
+ FROM trending_alerts ta
636
+ JOIN videos v ON ta.video_id = v.video_id
637
+ ORDER BY ta.detected_at DESC LIMIT 30
638
+ ''')
639
+ alerts = c.fetchall()
640
+ conn.close()
641
+
642
  if not alerts:
643
+ # 알림 없으면 현재 인기 급상승 영상 직접 조회
644
+ try:
645
+ resp = youtube.videos().list(part="snippet,statistics", chart="mostPopular", regionCode="KR", maxResults=20).execute()
646
+
647
+ html = '''
648
+ <div style="background:#FACC15; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
649
+ <h3 style="font-family:'Bangers',cursive; margin:0;">🔥 현재 급상승 인기 영상 (한국)</h3>
650
+ <p style="margin:5px 0 0 0; font-size:0.9rem;">데이터 축적 후 변화율 기반 알림이 표시됩니다</p>
651
+ </div>
652
+ <div style="display:flex; flex-wrap:wrap; gap:15px;">
653
+ '''
654
+
655
+ for i, item in enumerate(resp.get("items", [])[:20], 1):
656
+ snip = item["snippet"]
657
+ stats = item["statistics"]
658
+ vid = item["id"]
659
+ title = snip["title"][:30] + "..." if len(snip["title"]) > 30 else snip["title"]
660
+ views = format_count(int(stats.get("viewCount", 0)))
661
+ url = f"https://youtube.com/watch?v={vid}"
662
+ thumb = snip["thumbnails"]["medium"]["url"]
663
+
664
+ html += f'''
665
+ <div style="width:180px; background:#FFF; border:3px solid #1F2937; border-radius:8px; padding:10px; box-shadow:4px 4px 0 #1F2937;">
666
+ <a href="{url}" target="_blank"><img src="{thumb}" style="width:100%; border-radius:5px; border:2px solid #1F2937;"></a>
667
+ <p style="margin:8px 0 5px 0; font-size:12px; font-weight:700; color:#1F2937;">{i}. {title}</p>
668
+ <p style="margin:0; font-size:14px; color:#EF4444; font-weight:700;">👀 {views}</p>
669
+ </div>
670
+ '''
671
+
672
+ html += '</div>'
673
+ return html
674
+ except Exception as e:
675
+ return f"<p>데이터 로드 오류: {e}</p>"
676
 
677
  html = '''
678
+ <div style="background:#EF4444; color:white; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
679
+ <h3 style="font-family:'Bangers',cursive; margin:0;">🔥 급상승 알림 (조회수 20%+ 증가)</h3>
680
+ </div>
681
+ <table style="width:100%; border-collapse:collapse; font-family:'Comic Neue',cursive;">
682
+ <thead><tr style="background:#1F2937; color:#FACC15;">
683
+ <th style="padding:10px; border:2px solid #1F2937;">영상</th>
684
+ <th style="padding:10px;">채널</th>
685
+ <th style="padding:10px;">이전</th>
686
+ <th style="padding:10px;">현재</th>
687
+ <th style="padding:10px;">증가율</th>
688
+ <th style="padding:10px;">감지시간</th>
689
  </tr></thead><tbody>
690
  '''
691
+
692
+ for vid, title, channel, old_v, new_v, pct, detected in alerts:
693
+ title = title[:25] + "..." if len(title) > 25 else title
694
+ url = f"https://youtube.com/watch?v={vid}"
695
+ html += f'''
696
+ <tr style="background:#FFF; border-bottom:2px solid #1F2937;">
697
+ <td style="padding:10px;"><a href="{url}" target="_blank" style="color:#3B82F6; font-weight:700;">{title}</a></td>
698
+ <td style="padding:10px;">{channel}</td>
699
+ <td style="padding:10px;">{format_count(old_v)}</td>
700
+ <td style="padding:10px;">{format_count(new_v)}</td>
701
+ <td style="padding:10px; color:#EF4444; font-weight:700;">+{pct:.1f}%</td>
702
+ <td style="padding:10px; font-size:12px;">{detected[:16].replace("T"," ")}</td>
703
+ </tr>
704
+ '''
705
+
706
  html += '</tbody></table>'
707
  return html
708
 
709
  def show_top_growing():
710
+ """급성장 TOP - DB + 실시간 조회"""
711
+ conn = sqlite3.connect("youtube_data.db")
712
+ c = conn.cursor()
713
+ cutoff = (datetime.now() - timedelta(hours=48)).isoformat()
714
+
715
+ c.execute('''
716
+ SELECT v.video_id, v.title, v.channel_name, v.thumbnail,
717
+ MIN(vs.views) as min_v, MAX(vs.views) as max_v,
718
+ ((MAX(vs.views) - MIN(vs.views)) * 100.0 / MIN(vs.views)) as growth
719
+ FROM videos v
720
+ JOIN video_stats vs ON v.video_id = vs.video_id
721
+ WHERE vs.recorded_at > ?
722
+ GROUP BY v.video_id
723
+ HAVING min_v > 0 AND max_v > min_v
724
+ ORDER BY growth DESC LIMIT 20
725
+ ''', (cutoff,))
726
+ results = c.fetchall()
727
+ conn.close()
728
+
729
  if not results:
730
+ # DB 없으면 실시간 인기 영상 표시
731
+ try:
732
+ resp = youtube.videos().list(part="snippet,statistics", chart="mostPopular", regionCode="KR", maxResults=20).execute()
733
+
734
+ html = '''
735
+ <div style="background:#3B82F6; color:white; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
736
+ <h3 style="font-family:'Bangers',cursive; margin:0;">📈 현재 인기 급상승 TOP 20</h3>
737
+ <p style="margin:5px 0 0 0; font-size:0.9rem;">검색을 여러 번 수행하면 성장률 데이터가 축적됩니다</p>
738
+ </div>
739
+ <div style="display:flex; flex-wrap:wrap; gap:15px;">
740
+ '''
741
+
742
+ items = resp.get("items", [])
743
+ for i, item in enumerate(items[:20], 1):
744
+ snip = item["snippet"]
745
+ stats = item["statistics"]
746
+ views = int(stats.get("viewCount", 0))
747
+ likes = int(stats.get("likeCount", 0))
748
+
749
+ # 간단한 engagement 계산
750
+ engagement = (likes / views * 100) if views > 0 else 0
751
+
752
+ title = snip["title"][:25] + "..." if len(snip["title"]) > 25 else snip["title"]
753
+ url = f"https://youtube.com/watch?v={item['id']}"
754
+ thumb = snip["thumbnails"]["medium"]["url"]
755
+
756
+ html += f'''
757
+ <div style="width:180px; background:#FFF; border:3px solid #1F2937; border-radius:8px; padding:10px; box-shadow:4px 4px 0 #1F2937;">
758
+ <a href="{url}" target="_blank"><img src="{thumb}" style="width:100%; border-radius:5px; border:2px solid #1F2937;"></a>
759
+ <p style="margin:8px 0 5px 0; font-size:12px; font-weight:700;">{i}. {title}</p>
760
+ <p style="margin:0; font-size:11px; color:#666;">{snip["channelTitle"][:15]}</p>
761
+ <p style="margin:5px 0 0 0; font-size:14px; color:#3B82F6; font-weight:700;">👀 {format_count(views)}</p>
762
+ <p style="margin:2px 0 0 0; font-size:12px; color:#EF4444;">❤️ {engagement:.2f}%</p>
763
+ </div>
764
+ '''
765
+
766
+ html += '</div>'
767
+ return html
768
+ except Exception as e:
769
+ return f"<p>데이터 로드 오류: {e}</p>"
770
+
771
+ html = '''
772
+ <div style="background:#3B82F6; color:white; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
773
+ <h3 style="font-family:'Bangers',cursive; margin:0;">📈 48시간 급성장 TOP 20</h3>
774
+ </div>
775
+ <div style="display:flex; flex-wrap:wrap; gap:15px;">
776
+ '''
777
 
778
+ for i, (vid, title, channel, thumb, min_v, max_v, growth) in enumerate(results, 1):
 
779
  title = title[:25] + "..." if len(title) > 25 else title
780
+ url = f"https://youtube.com/watch?v={vid}"
781
+
782
+ html += f'''
783
+ <div style="width:180px; background:#FFF; border:3px solid #1F2937; border-radius:8px; padding:10px; box-shadow:4px 4px 0 #1F2937;">
784
+ <a href="{url}" target="_blank"><img src="{thumb}" style="width:100%; border-radius:5px; border:2px solid #1F2937;"></a>
785
+ <p style="margin:8px 0 5px 0; font-size:12px; font-weight:700;">{i}. {title}</p>
786
+ <p style="margin:0; font-size:11px; color:#666;">{channel[:15]}</p>
787
+ <p style="margin:5px 0 0 0; font-size:16px; color:#EF4444; font-weight:700; font-family:'Bangers',cursive;">📈 +{growth:.1f}%</p>
788
+ <p style="margin:2px 0 0 0; font-size:11px; color:#888;">{format_count(int(min_v))} → {format_count(int(max_v))}</p>
789
+ </div>
790
+ '''
791
+
792
  html += '</div>'
793
  return html
794
 
795
  def show_search_history():
796
+ conn = sqlite3.connect("youtube_data.db")
797
+ c = conn.cursor()
798
+ c.execute('SELECT keyword,country,language,sort_by,results_count,searched_at FROM search_history ORDER BY searched_at DESC LIMIT 50')
799
+ history = c.fetchall()
800
+ conn.close()
801
+
802
  if not history:
803
+ return "<p style='font-family:Comic Neue,cursive;'>검색 기록이 없습니다.</p>"
804
+
805
+ html = '''
806
+ <div style="background:#1F2937; color:#FACC15; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
807
+ <h3 style="font-family:'Bangers',cursive; margin:0;">🕐 최근 검색 기록</h3>
808
+ </div>
809
+ <table style="width:100%; border-collapse:collapse; font-family:'Comic Neue',cursive;">
810
+ <thead><tr style="background:#FACC15; color:#1F2937;">
811
+ <th style="padding:10px; border:2px solid #1F2937;">검색어</th>
812
+ <th style="padding:10px;">국가</th><th style="padding:10px;">언어</th>
813
+ <th style="padding:10px;">정렬</th><th style="padding:10px;">결과</th><th style="padding:10px;">시간</th>
814
+ </tr></thead><tbody>
815
+ '''
816
+
817
+ for kw, country, lang, sort_by, cnt, searched in history:
818
+ html += f'''
819
+ <tr style="background:#FFF; border-bottom:2px solid #1F2937;">
820
+ <td style="padding:10px; font-weight:700;">{kw}</td>
821
+ <td style="padding:10px;">{country}</td><td style="padding:10px;">{lang}</td>
822
+ <td style="padding:10px;">{sort_by}</td><td style="padding:10px;">{cnt}</td>
823
+ <td style="padding:10px; font-size:12px;">{searched[:16].replace("T"," ")}</td>
824
+ </tr>
825
+ '''
826
+
827
  html += '</tbody></table>'
828
  return html
829
 
830
  # 초기 트렌딩
831
  initial_trending = get_real_trending_keywords("KR", "ko")
832
 
833
+ with gr.Blocks(css=css) as demo:
834
+ gr.HTML('''
835
+ <div style="text-align:center; margin:20px 0;">
836
+ <a href="https://www.humangen.ai" target="_blank">
837
+ <img src="https://img.shields.io/static/v1?label=🏠 HOME&message=HUMANGEN.AI&color=0000ff&labelColor=ffcc00&style=for-the-badge">
838
+ </a>
839
+ </div>
840
+ ''')
841
 
842
+ gr.Markdown("# 🎬 YOUTUBE TREND ANALYZER 📊", elem_classes="header-text")
843
+
844
+ db_stats = gr.Markdown("📊 DB 로딩중...")
845
 
846
  with gr.Tabs():
847
  with gr.Tab("🔍 검색"):
848
+ gr.Markdown("### 🔥 실시간 인기 검색어")
849
+ trending = gr.Radio(choices=initial_trending, label="클릭시 자동 입력", interactive=True)
850
 
851
  with gr.Row():
852
  keyword = gr.Textbox(label="검색어", placeholder="검색어 입력...", scale=3)
853
  btn = gr.Button("🔍 검색", variant="primary", scale=1)
854
+ refresh = gr.Button("🔄 갱신", variant="secondary", scale=1)
855
 
856
  with gr.Row():
857
+ country = gr.Dropdown(list(COUNTRIES.keys()), value="한국", label="국가")
858
  language = gr.Dropdown(list(LANGUAGES.keys()), value="자동 (국가 기반)", label="언어")
859
  sort_by = gr.Dropdown(list(SORT_OPTIONS.keys()), value="조회수 순", label="정렬")
860
  date_filter = gr.Dropdown(list(DATE_OPTIONS.keys()), value="전체 기간", label="기간")
861
+ max_results = gr.Slider(10, 1000, value=100, step=10, label="결과 수")
862
 
863
+ output = gr.HTML()
864
 
865
  with gr.Tab("🔥 급상승"):
866
+ alerts_btn = gr.Button("🔄 새로고침", variant="primary")
867
+ alerts_out = gr.HTML()
868
 
869
  with gr.Tab("📈 급성장 TOP"):
870
+ growing_btn = gr.Button("🔄 새로고침", variant="primary")
871
+ growing_out = gr.HTML()
872
 
873
  with gr.Tab("🕐 검색 기록"):
874
+ history_btn = gr.Button("🔄 새로고침", variant="primary")
875
+ history_out = gr.HTML()
876
+
877
+ # 이벤트
878
+ trending.change(use_trending_keyword, trending, keyword)
879
+ country.change(update_trending, country, trending)
880
+ btn.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
881
+ keyword.submit(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
882
+ refresh.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
883
+
884
+ alerts_btn.click(show_trending_alerts, outputs=alerts_out)
885
+ growing_btn.click(show_top_growing, outputs=growing_out)
886
+ history_btn.click(show_search_history, outputs=history_out)
887
 
888
+ demo.launch()