Update app.py
Browse files
app.py
CHANGED
|
@@ -12,6 +12,9 @@ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
|
| 12 |
youtube = build("youtube", "v3", developerKey=API_KEY)
|
| 13 |
groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
# ============================================
|
| 16 |
# 🎨 Comic Classic Theme CSS
|
| 17 |
# ============================================
|
|
@@ -37,7 +40,6 @@ footer, .footer, .gradio-container footer, .built-with, [class*="footer"] {
|
|
| 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 {
|
|
@@ -54,13 +56,10 @@ footer, .footer, .gradio-container footer, .built-with, [class*="footer"] {
|
|
| 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;
|
| 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;
|
|
@@ -70,105 +69,96 @@ footer, .footer, .gradio-container footer, .built-with, [class*="footer"] {
|
|
| 70 |
box-shadow: 4px 4px 0px #1F2937 !important;
|
| 71 |
}
|
| 72 |
|
| 73 |
-
textarea, input[type="text"]
|
| 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
|
| 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 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
"
|
| 148 |
-
"
|
| 149 |
-
"
|
| 150 |
-
"
|
| 151 |
-
"
|
| 152 |
-
"
|
| 153 |
-
"
|
| 154 |
-
"
|
|
|
|
|
|
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
LANGUAGES = {
|
| 158 |
-
"
|
| 159 |
-
"
|
| 160 |
-
"
|
| 161 |
-
"
|
| 162 |
-
"
|
| 163 |
-
"
|
| 164 |
-
"
|
| 165 |
-
"
|
| 166 |
-
"루마니아어": "ro", "스와힐리어": "sw", "그리스어": "el", "체코어": "cs",
|
| 167 |
-
"헝가리어": "hu", "스웨덴어": "sv", "히브리어": "he", "핀란드어": "fi", "노르웨이어": "no",
|
| 168 |
}
|
| 169 |
|
| 170 |
-
SORT_OPTIONS = {"
|
| 171 |
-
DATE_OPTIONS = {"
|
| 172 |
|
| 173 |
def format_count(count):
|
| 174 |
if count >= 1000000000: return f"{count/1000000000:.1f}B"
|
|
@@ -176,201 +166,132 @@ def format_count(count):
|
|
| 176 |
elif count >= 1000: return f"{count/1000:.1f}K"
|
| 177 |
return str(count)
|
| 178 |
|
| 179 |
-
def
|
| 180 |
-
"""Groq API
|
| 181 |
-
if not groq_client
|
| 182 |
-
return
|
| 183 |
-
|
| 184 |
try:
|
| 185 |
-
# 상위 50개만 분석 (토큰 제한)
|
| 186 |
-
sample = videos_data[:50]
|
| 187 |
-
|
| 188 |
-
video_info = "\n".join([
|
| 189 |
-
f"#{i+1}. 제목:{v['title'][:40]}, 채널:{v['channel_name'][:15]}, "
|
| 190 |
-
f"조회수:{v['views']}, 좋아요:{v['likes']}, 댓글:{v['comments']}, 구독자:{v.get('subs',0)}"
|
| 191 |
-
for i, v in enumerate(sample)
|
| 192 |
-
])
|
| 193 |
-
|
| 194 |
-
prompt = f"""YouTube 영상 목록을 분석하여 각 영상의 종합 품질 등급을 매겨주세요.
|
| 195 |
-
|
| 196 |
-
평가 기준:
|
| 197 |
-
- 조회수 대비 좋아요/댓글 비율 (engagement)
|
| 198 |
-
- 채널 구독자 대비 조회수 (viral potential)
|
| 199 |
-
- 전반적인 인기도
|
| 200 |
-
|
| 201 |
-
등급 (숫자로만):
|
| 202 |
-
0=없음(평균이하), 1=♥(평균), 2=⭐(좋음), 3=⭐⭐(매우좋음), 4=⭐⭐⭐(최고)
|
| 203 |
-
|
| 204 |
-
각 영상 번호와 등급만 응답하세요.
|
| 205 |
-
형식: 1:3,2:2,3:4,4:1,5:0
|
| 206 |
-
|
| 207 |
-
영상 목록:
|
| 208 |
-
{video_info}
|
| 209 |
-
|
| 210 |
-
응답 (번호:등급 형식으로만):"""
|
| 211 |
-
|
| 212 |
-
# Groq API 호출 (스트리밍)
|
| 213 |
completion = groq_client.chat.completions.create(
|
| 214 |
model="openai/gpt-oss-120b",
|
| 215 |
messages=[{"role": "user", "content": prompt}],
|
| 216 |
temperature=0.7,
|
| 217 |
-
max_completion_tokens=
|
| 218 |
top_p=1,
|
| 219 |
stream=True,
|
| 220 |
stop=None
|
| 221 |
)
|
| 222 |
-
|
| 223 |
-
# 스트리밍 응답 수집
|
| 224 |
-
result_text = ""
|
| 225 |
for chunk in completion:
|
| 226 |
if chunk.choices[0].delta.content:
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
# 응답 파싱
|
| 230 |
-
ratings = {}
|
| 231 |
-
pairs = re.findall(r'(\d+):(\d)', result_text)
|
| 232 |
-
for idx, rating in pairs:
|
| 233 |
-
ratings[int(idx)-1] = int(rating)
|
| 234 |
-
|
| 235 |
-
# 나머지 영상은 로컬 알고리즘으로
|
| 236 |
-
if len(videos_data) > 50:
|
| 237 |
-
local_ratings = calculate_local_rating(videos_data[50:])
|
| 238 |
-
for k, v in local_ratings.items():
|
| 239 |
-
ratings[k + 50] = v
|
| 240 |
-
|
| 241 |
-
return ratings
|
| 242 |
-
|
| 243 |
except Exception as e:
|
| 244 |
-
|
| 245 |
-
return calculate_local_rating(videos_data)
|
| 246 |
|
| 247 |
-
def
|
| 248 |
-
"""
|
| 249 |
-
|
| 250 |
-
|
| 251 |
if not videos_data:
|
| 252 |
-
return
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
views_list = [v['views'] for v in videos_data if v['views'] > 0]
|
| 255 |
-
if not views_list:
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
avg_views = sum(views_list) / len(views_list)
|
| 259 |
-
max_views = max(views_list)
|
| 260 |
|
| 261 |
for i, v in enumerate(videos_data):
|
| 262 |
-
views = v['views']
|
| 263 |
-
likes = v['likes']
|
| 264 |
-
comments = v['comments']
|
| 265 |
-
subs = v.get('subs', 0)
|
| 266 |
-
|
| 267 |
score = 0
|
| 268 |
-
|
| 269 |
-
# 조회수 점수 (0-40)
|
| 270 |
if views > 0:
|
| 271 |
-
score += min(40, (views
|
| 272 |
-
|
| 273 |
-
# engagement 점수 (0-30)
|
| 274 |
-
if views > 0:
|
| 275 |
-
engagement = ((likes + comments * 2) / views) * 100
|
| 276 |
-
score += min(30, engagement * 3)
|
| 277 |
-
|
| 278 |
-
# viral 점수 (0-30)
|
| 279 |
if subs > 0 and views > 0:
|
| 280 |
-
score += min(30, (views
|
| 281 |
elif views > avg_views:
|
| 282 |
score += 15
|
| 283 |
|
| 284 |
-
# 등급 결정
|
| 285 |
if score >= 70: ratings[i] = 4
|
| 286 |
elif score >= 50: ratings[i] = 3
|
| 287 |
elif score >= 30: ratings[i] = 2
|
| 288 |
elif score >= 15: ratings[i] = 1
|
| 289 |
else: ratings[i] = 0
|
| 290 |
-
|
| 291 |
return ratings
|
| 292 |
|
| 293 |
def get_rating_display(rating):
|
| 294 |
-
"""등급을 이모지로 변환"""
|
| 295 |
return {0: "", 1: "♥", 2: "⭐", 3: "⭐⭐", 4: "⭐⭐⭐"}.get(rating, "")
|
| 296 |
|
| 297 |
-
def get_real_trending_keywords(region_code="
|
| 298 |
try:
|
| 299 |
-
response = youtube.videos().list(
|
| 300 |
-
|
| 301 |
-
regionCode=region_code if region_code else "US", maxResults=50
|
| 302 |
-
).execute()
|
| 303 |
-
|
| 304 |
-
keywords = []
|
| 305 |
-
seen = set()
|
| 306 |
-
|
| 307 |
for item in response.get("items", []):
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
tags = item["snippet"].get("tags", [])
|
| 311 |
-
|
| 312 |
-
for tag in tags[:3]:
|
| 313 |
-
tag_clean = tag.strip().lower()
|
| 314 |
-
if tag_clean not in seen and 2 <= len(tag_clean) <= 20:
|
| 315 |
keywords.append(tag)
|
| 316 |
-
seen.add(
|
| 317 |
-
|
| 318 |
-
if channel not in seen:
|
| 319 |
keywords.append(channel)
|
| 320 |
seen.add(channel.lower())
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
for word in words[:2]:
|
| 324 |
-
if word.lower() not in seen and word.lower() not in ['the','and','for','with']:
|
| 325 |
-
keywords.append(word)
|
| 326 |
-
seen.add(word.lower())
|
| 327 |
-
|
| 328 |
-
if len(keywords) >= 30:
|
| 329 |
-
break
|
| 330 |
-
|
| 331 |
-
popular = {
|
| 332 |
-
"KR": ["뉴진스","BTS","먹방","브이로그","게임","shorts","드라마","영화","음악","코딩","AI","축구"],
|
| 333 |
-
"US": ["Taylor Swift","NBA","NFL","gaming","vlog","music","AI","coding","shorts","news"],
|
| 334 |
-
"JP": ["アニメ","ゲーム","音楽","vlog","料理","猫","映画","ASMR","shorts","AI"],
|
| 335 |
-
}
|
| 336 |
-
for kw in popular.get(region_code, popular["US"]):
|
| 337 |
-
if kw.lower() not in seen:
|
| 338 |
-
keywords.append(kw)
|
| 339 |
-
seen.add(kw.lower())
|
| 340 |
-
|
| 341 |
-
return keywords[:20] if keywords else ["AI","ChatGPT","뉴진스","BTS","게임","먹방","브이로그","shorts"]
|
| 342 |
except:
|
| 343 |
-
return ["AI","ChatGPT","
|
| 344 |
|
| 345 |
def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
|
| 346 |
conn = sqlite3.connect("youtube_data.db")
|
| 347 |
c = conn.cursor()
|
| 348 |
now = datetime.now().isoformat()
|
| 349 |
-
|
| 350 |
-
c.execute('INSERT INTO search_history (keyword,country,language,sort_by,results_count,searched_at) VALUES (?,?,?,?,?,?)',
|
| 351 |
-
(keyword, country, language, sort_by, len(videos_data), now))
|
| 352 |
-
|
| 353 |
for video in videos_data:
|
| 354 |
-
c.execute('INSERT OR IGNORE INTO videos
|
| 355 |
(video['video_id'], video['title'], video['channel_id'], video['channel_name'], video['thumbnail'], video['published_at'], now))
|
| 356 |
-
|
| 357 |
c.execute('SELECT views FROM video_stats WHERE video_id=? ORDER BY recorded_at DESC LIMIT 1', (video['video_id'],))
|
| 358 |
prev = c.fetchone()
|
| 359 |
-
|
| 360 |
-
c.execute('INSERT INTO video_stats (video_id,views,likes,comments,recorded_at) VALUES (?,?,?,?,?)',
|
| 361 |
-
(video['video_id'], video['views'], video['likes'], video['comments'], now))
|
| 362 |
-
|
| 363 |
if prev and prev[0] > 0:
|
| 364 |
change = ((video['views'] - prev[0]) / prev[0]) * 100
|
| 365 |
if change >= 20:
|
| 366 |
-
c.execute('INSERT INTO trending_alerts (video_id,
|
| 367 |
-
(video['video_id'], 'views_surge', prev[0], video['views'], change, now))
|
| 368 |
-
|
| 369 |
for ch_id, subs in channels_data.items():
|
| 370 |
-
c.execute('INSERT OR IGNORE INTO channels
|
| 371 |
if isinstance(subs, int):
|
| 372 |
-
c.execute('INSERT INTO channel_stats
|
| 373 |
-
|
| 374 |
conn.commit()
|
| 375 |
conn.close()
|
| 376 |
|
|
@@ -378,400 +299,458 @@ def get_db_stats():
|
|
| 378 |
conn = sqlite3.connect("youtube_data.db")
|
| 379 |
c = conn.cursor()
|
| 380 |
stats = {}
|
| 381 |
-
for
|
| 382 |
-
c.execute(f"SELECT COUNT(*) FROM {
|
| 383 |
-
stats[
|
| 384 |
conn.close()
|
| 385 |
return stats
|
| 386 |
|
| 387 |
def update_trending(country):
|
| 388 |
region, lang = COUNTRIES.get(country, ("", ""))
|
| 389 |
-
|
| 390 |
-
return gr.update(choices=keywords, value=None)
|
| 391 |
|
| 392 |
def use_trending_keyword(kw):
|
| 393 |
return kw if kw else ""
|
| 394 |
|
| 395 |
def search_videos(keyword, country, language, sort_by, date_filter, max_results):
|
| 396 |
if not keyword or not keyword.strip():
|
| 397 |
-
return "⚠️
|
| 398 |
|
| 399 |
max_results = int(max_results)
|
| 400 |
-
all_items = []
|
| 401 |
-
next_page = None
|
| 402 |
-
|
| 403 |
region_code, default_lang = COUNTRIES.get(country, ("", ""))
|
| 404 |
-
lang_code = default_lang if language == "
|
| 405 |
|
| 406 |
params = {"q": keyword, "part": "snippet", "type": "video", "order": SORT_OPTIONS[sort_by]}
|
| 407 |
if region_code: params["regionCode"] = region_code
|
| 408 |
if lang_code: params["relevanceLanguage"] = lang_code
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
-
if date_filter != "전체 기간":
|
| 411 |
-
now = datetime.utcnow()
|
| 412 |
-
deltas = {"오늘": 1, "이번 주": 7, "이번 달": 30, "올해": 365}
|
| 413 |
-
after = now - timedelta(days=deltas.get(date_filter, 0))
|
| 414 |
-
params["publishedAfter"] = after.strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 415 |
-
|
| 416 |
-
# 페이지네이션으로 최대 1000개까지
|
| 417 |
while len(all_items) < max_results:
|
| 418 |
params["maxResults"] = min(50, max_results - len(all_items))
|
| 419 |
if next_page: params["pageToken"] = next_page
|
| 420 |
-
|
| 421 |
resp = youtube.search().list(**params).execute()
|
| 422 |
items = resp.get("items", [])
|
| 423 |
if not items: break
|
| 424 |
-
|
| 425 |
all_items.extend(items)
|
| 426 |
next_page = resp.get("nextPageToken")
|
| 427 |
if not next_page: break
|
| 428 |
|
| 429 |
-
if not all_items:
|
| 430 |
-
return "검색 결과가 없습니다.", ""
|
| 431 |
|
| 432 |
video_ids = [item["id"]["videoId"] for item in all_items]
|
| 433 |
channel_ids = list(set([item["snippet"]["channelId"] for item in all_items]))
|
| 434 |
|
| 435 |
-
# 비디오 통계
|
| 436 |
video_stats = {}
|
| 437 |
for i in range(0, len(video_ids), 50):
|
| 438 |
-
|
| 439 |
-
details = youtube.videos().list(id=",".join(batch), part="statistics").execute()
|
| 440 |
-
for v in details.get("items", []):
|
| 441 |
s = v["statistics"]
|
| 442 |
-
video_stats[v["id"]] = {
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
"comments": int(s.get("commentCount", 0)),
|
| 446 |
-
}
|
| 447 |
-
|
| 448 |
-
# 채널 통계
|
| 449 |
-
channel_subs = {}
|
| 450 |
-
channel_subs_raw = {}
|
| 451 |
for i in range(0, len(channel_ids), 50):
|
| 452 |
-
|
| 453 |
-
details = youtube.channels().list(id=",".join(batch), part="statistics").execute()
|
| 454 |
-
for ch in details.get("items", []):
|
| 455 |
sub = ch["statistics"].get("subscriberCount", "0")
|
| 456 |
if sub:
|
| 457 |
channel_subs_raw[ch["id"]] = int(sub)
|
| 458 |
channel_subs[ch["id"]] = format_count(int(sub))
|
| 459 |
-
else:
|
| 460 |
-
channel_subs[ch["id"]] = "비공개"
|
| 461 |
|
| 462 |
-
# 데이터 준비
|
| 463 |
videos_data = []
|
| 464 |
for item in all_items:
|
| 465 |
-
vid = item["id"]["videoId"]
|
| 466 |
-
snip = item["snippet"]
|
| 467 |
st = video_stats.get(vid, {})
|
| 468 |
videos_data.append({
|
| 469 |
-
"video_id": vid, "title": snip["title"],
|
| 470 |
-
"
|
| 471 |
-
"
|
| 472 |
-
"
|
| 473 |
-
"views": st.get("views", 0), "likes": st.get("likes", 0),
|
| 474 |
-
"comments": st.get("comments", 0),
|
| 475 |
"subs": channel_subs_raw.get(snip["channelId"], 0),
|
| 476 |
})
|
| 477 |
|
| 478 |
-
# AI Pick 등급 계산 (Groq GPT-OSS-120B)
|
| 479 |
ai_ratings = get_ai_pick_rating(videos_data)
|
| 480 |
-
|
| 481 |
-
# DB 저장
|
| 482 |
save_to_db(videos_data, channel_subs_raw, keyword, country, language, sort_by)
|
| 483 |
|
| 484 |
-
|
|
|
|
| 485 |
html = f'''
|
| 486 |
<style>
|
| 487 |
-
.yt-
|
| 488 |
-
.yt-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
}}
|
| 493 |
-
.yt-
|
| 494 |
-
.yt-
|
| 495 |
-
.
|
| 496 |
-
.
|
| 497 |
-
.
|
| 498 |
-
.
|
| 499 |
-
.
|
| 500 |
-
.yt-table a:hover {{ color:#EF4444; }}
|
| 501 |
-
.yt-rank {{ font-family:'Bangers',cursive; color:#EF4444; text-align:center; font-size:1.1rem; }}
|
| 502 |
-
.yt-views {{ font-weight:700; color:#3B82F6; }}
|
| 503 |
-
.ai-pick {{ color:#FACC15; text-shadow:1px 1px 0 #1F2937; font-size:1.1rem; }}
|
| 504 |
-
.result-header {{
|
| 505 |
-
background:#3B82F6; color:white; padding:15px; border-radius:8px;
|
| 506 |
-
border:3px solid #1F2937; box-shadow:4px 4px 0 #1F2937;
|
| 507 |
-
font-family:'Comic Neue',cursive; font-weight:700; margin-bottom:15px;
|
| 508 |
-
}}
|
| 509 |
</style>
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
</div>
|
| 515 |
-
|
| 516 |
-
<
|
| 517 |
-
|
| 518 |
-
<
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
<th data-col="9" data-type="str" style="width:85px;">게시일</th>
|
| 529 |
-
</tr></thead>
|
| 530 |
-
<tbody>
|
| 531 |
-
'''
|
| 532 |
|
| 533 |
for i, v in enumerate(videos_data):
|
| 534 |
-
vid = v["video_id"]
|
| 535 |
title = v["title"][:45] + "..." if len(v["title"]) > 45 else v["title"]
|
| 536 |
channel = v["channel_name"][:12] + "..." if len(v["channel_name"]) > 12 else v["channel_name"]
|
| 537 |
-
url = f"https://youtube.com/watch?v={
|
| 538 |
-
ch_url = f"https://youtube.com/channel/{v['channel_id']}"
|
| 539 |
-
published = v["published_at"][:10]
|
| 540 |
-
|
| 541 |
-
views = v["views"]
|
| 542 |
-
likes = v["likes"]
|
| 543 |
-
comments = v["comments"]
|
| 544 |
-
subs = v["subs"]
|
| 545 |
-
|
| 546 |
rating = ai_ratings.get(i, 0)
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
rank_style = ""
|
| 550 |
-
if i == 0: rank_style = "color:#FFD700; font-size:1.3rem;"
|
| 551 |
-
elif i == 1: rank_style = "color:#C0C0C0; font-size:1.2rem;"
|
| 552 |
-
elif i == 2: rank_style = "color:#CD7F32; font-size:1.2rem;"
|
| 553 |
|
| 554 |
-
html += f'''
|
| 555 |
-
|
| 556 |
-
<td
|
| 557 |
-
<td><a href="{url}" target="_blank"
|
| 558 |
-
<td data-v="{v['title']}"><a href="{url}" target="_blank">{title}</a></td>
|
| 559 |
<td data-v="{v['channel_name']}"><a href="{ch_url}" target="_blank">{channel}</a></td>
|
| 560 |
-
<td data-v="{subs}">{format_count(subs)}</td>
|
| 561 |
-
<td
|
| 562 |
-
<td data-v="{likes}">{format_count(likes)}</td>
|
| 563 |
-
<td data-v="{comments}">{format_count(comments)}</td>
|
| 564 |
-
<td class="ai-pick" data-v="{rating}">{
|
| 565 |
-
<td data-v="{
|
| 566 |
-
</tr>
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
html += '''
|
| 570 |
-
</tbody>
|
| 571 |
-
</table>
|
| 572 |
-
</div>
|
| 573 |
-
|
| 574 |
<script>
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
var
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
if(sortCol === col){ sortAsc = !sortAsc; }
|
| 589 |
-
else { sortCol = col; sortAsc = true; }
|
| 590 |
-
|
| 591 |
-
headers.forEach(function(h){ h.classList.remove('sort-asc','sort-desc'); });
|
| 592 |
-
this.classList.add(sortAsc ? 'sort-asc' : 'sort-desc');
|
| 593 |
-
|
| 594 |
-
rows.sort(function(a,b){
|
| 595 |
-
var aVal = a.cells[col].dataset.v || a.cells[col].textContent;
|
| 596 |
-
var bVal = b.cells[col].dataset.v || b.cells[col].textContent;
|
| 597 |
-
var result = 0;
|
| 598 |
-
if(type === 'num'){
|
| 599 |
-
result = parseFloat(aVal||0) - parseFloat(bVal||0);
|
| 600 |
-
} else {
|
| 601 |
-
result = (aVal||'').localeCompare(bVal||'','ko');
|
| 602 |
-
}
|
| 603 |
-
return sortAsc ? result : -result;
|
| 604 |
-
});
|
| 605 |
-
|
| 606 |
-
rows.forEach(function(row){ tbody.appendChild(row); });
|
| 607 |
-
};
|
| 608 |
-
});
|
| 609 |
-
})();
|
| 610 |
-
</script>
|
| 611 |
-
'''
|
| 612 |
|
| 613 |
stats = get_db_stats()
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
return html, stats_html
|
| 617 |
|
| 618 |
def show_trending_alerts():
|
| 619 |
-
"""
|
| 620 |
conn = sqlite3.connect("youtube_data.db")
|
| 621 |
c = conn.cursor()
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
SELECT ta.video_id, v.title, v.channel_name, ta.old_value, ta.new_value, ta.change_percent, ta.detected_at
|
| 625 |
-
FROM trending_alerts ta
|
| 626 |
-
JOIN videos v ON ta.video_id = v.video_id
|
| 627 |
-
ORDER BY ta.detected_at DESC LIMIT 30
|
| 628 |
-
''')
|
| 629 |
alerts = c.fetchall()
|
| 630 |
conn.close()
|
| 631 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
if not alerts:
|
| 633 |
try:
|
| 634 |
-
resp = youtube.videos().list(part="snippet,statistics", chart="mostPopular", regionCode="
|
| 635 |
-
|
| 636 |
-
html = '''
|
| 637 |
-
<div style="background:#FACC15; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px; font-family:'Comic Neue',cursive;">
|
| 638 |
-
<h3 style="font-family:'Bangers',cursive; margin:0;">🔥 현재 급상승 인기 영상 (한국)</h3>
|
| 639 |
-
<p style="margin:5px 0 0 0; font-size:0.9rem;">검색을 여러 번 실행하면 변화율 기반 알림이 표시됩니다</p>
|
| 640 |
-
</div>
|
| 641 |
-
<div style="display:flex; flex-wrap:wrap; gap:15px;">
|
| 642 |
-
'''
|
| 643 |
-
|
| 644 |
for i, item in enumerate(resp.get("items", [])[:20], 1):
|
| 645 |
-
snip = item["snippet"]
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
<div style="width:180px; background:#FFF; border:3px solid #1F2937; border-radius:8px; padding:10px; box-shadow:4px 4px 0 #1F2937; font-family:'Comic Neue',cursive;">
|
| 654 |
-
<a href="{url}" target="_blank"><img src="{thumb}" style="width:100%; border-radius:5px; border:2px solid #1F2937;"></a>
|
| 655 |
-
<p style="margin:8px 0 5px 0; font-size:12px; font-weight:700; color:#1F2937;">{i}. {title}</p>
|
| 656 |
-
<p style="margin:0; font-size:14px; color:#EF4444; font-weight:700; font-family:'Bangers',cursive;">👀 {views}</p>
|
| 657 |
-
</div>
|
| 658 |
-
'''
|
| 659 |
-
|
| 660 |
-
html += '</div>'
|
| 661 |
-
return html
|
| 662 |
except Exception as e:
|
| 663 |
-
return f"<p
|
| 664 |
-
|
| 665 |
-
html = ''
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
<
|
| 672 |
-
<
|
| 673 |
-
<
|
| 674 |
-
</
|
| 675 |
-
''
|
| 676 |
-
|
| 677 |
-
for vid, title, channel, old_v, new_v, pct, detected in alerts:
|
| 678 |
-
title = title[:25] + "..." if len(title) > 25 else title
|
| 679 |
-
url = f"https://youtube.com/watch?v={vid}"
|
| 680 |
-
html += f'''
|
| 681 |
-
<tr style="background:#FFF; border-bottom:2px solid #1F2937;">
|
| 682 |
-
<td style="padding:10px;"><a href="{url}" target="_blank" style="color:#3B82F6; font-weight:700;">{title}</a></td>
|
| 683 |
-
<td style="padding:10px;">{channel}</td>
|
| 684 |
-
<td style="padding:10px;">{format_count(old_v)}</td>
|
| 685 |
-
<td style="padding:10px;">{format_count(new_v)}</td>
|
| 686 |
-
<td style="padding:10px; color:#EF4444; font-weight:700;">+{pct:.1f}%</td>
|
| 687 |
-
<td style="padding:10px; font-size:12px;">{detected[:16].replace("T"," ")}</td>
|
| 688 |
-
</tr>
|
| 689 |
-
'''
|
| 690 |
-
|
| 691 |
-
html += '</tbody></table>'
|
| 692 |
-
return html
|
| 693 |
|
| 694 |
def show_top_growing():
|
| 695 |
-
"""
|
| 696 |
conn = sqlite3.connect("youtube_data.db")
|
| 697 |
c = conn.cursor()
|
| 698 |
cutoff = (datetime.now() - timedelta(hours=48)).isoformat()
|
| 699 |
-
|
| 700 |
-
c.execute('''
|
| 701 |
-
SELECT v.video_id, v.title, v.channel_name, v.thumbnail,
|
| 702 |
MIN(vs.views) as min_v, MAX(vs.views) as max_v,
|
| 703 |
((MAX(vs.views) - MIN(vs.views)) * 100.0 / MIN(vs.views)) as growth
|
| 704 |
-
FROM videos v
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
GROUP BY v.video_id
|
| 708 |
-
HAVING min_v > 0 AND max_v > min_v
|
| 709 |
-
ORDER BY growth DESC LIMIT 20
|
| 710 |
-
''', (cutoff,))
|
| 711 |
results = c.fetchall()
|
| 712 |
conn.close()
|
| 713 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
if not results:
|
| 715 |
try:
|
| 716 |
-
resp = youtube.videos().list(part="snippet,statistics", chart="mostPopular", regionCode="
|
| 717 |
-
|
| 718 |
-
html = '''
|
| 719 |
-
<div style="background:#3B82F6; color:white; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
|
| 720 |
-
<h3 style="font-family:'Bangers',cursive; margin:0;">📈 현재 인기 급상승 TOP 20</h3>
|
| 721 |
-
<p style="margin:5px 0 0 0; font-size:0.9rem; font-family:'Comic Neue',cursive;">검색을 여러 번 실행하면 성장률 데이터가 축적됩니다</p>
|
| 722 |
-
</div>
|
| 723 |
-
<div style="display:flex; flex-wrap:wrap; gap:15px;">
|
| 724 |
-
'''
|
| 725 |
-
|
| 726 |
for i, item in enumerate(resp.get("items", [])[:20], 1):
|
| 727 |
-
snip = item["snippet"]
|
| 728 |
-
|
| 729 |
-
views = int(stats.get("viewCount", 0))
|
| 730 |
-
likes = int(stats.get("likeCount", 0))
|
| 731 |
engagement = (likes / views * 100) if views > 0 else 0
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
<p style="margin:0; font-size:11px; color:#666;">{snip["channelTitle"][:15]}</p>
|
| 742 |
-
<p style="margin:5px 0 0 0; font-size:14px; color:#3B82F6; font-weight:700; font-family:'Bangers',cursive;">👀 {format_count(views)}</p>
|
| 743 |
-
<p style="margin:2px 0 0 0; font-size:12px; color:#EF4444;">❤️ {engagement:.2f}%</p>
|
| 744 |
-
</div>
|
| 745 |
-
'''
|
| 746 |
-
|
| 747 |
-
html += '</div>'
|
| 748 |
-
return html
|
| 749 |
except Exception as e:
|
| 750 |
-
return f"<p
|
| 751 |
-
|
| 752 |
-
html = '''
|
| 753 |
-
<div style="background:#3B82F6; color:white; padding:15px; border:3px solid #1F2937; border-radius:8px; margin-bottom:15px;">
|
| 754 |
-
<h3 style="font-family:'Bangers',cursive; margin:0;">📈 48시간 급성장 TOP 20</h3>
|
| 755 |
-
</div>
|
| 756 |
-
<div style="display:flex; flex-wrap:wrap; gap:15px;">
|
| 757 |
-
'''
|
| 758 |
|
|
|
|
| 759 |
for i, (vid, title, channel, thumb, min_v, max_v, growth) in enumerate(results, 1):
|
| 760 |
-
title = title[:
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
<
|
| 766 |
-
<p style="margin:
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
|
| 776 |
def show_search_history():
|
| 777 |
conn = sqlite3.connect("youtube_data.db")
|
|
@@ -781,90 +760,142 @@ def show_search_history():
|
|
| 781 |
conn.close()
|
| 782 |
|
| 783 |
if not history:
|
| 784 |
-
return "<p style='font-family:Comic Neue,cursive
|
| 785 |
|
| 786 |
-
html = '''
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
<th style="padding:
|
| 793 |
-
|
| 794 |
-
<th style="padding:10px;">정렬</th><th style="padding:10px;">결과</th><th style="padding:10px;">시간</th>
|
| 795 |
-
</tr></thead><tbody>
|
| 796 |
-
'''
|
| 797 |
|
| 798 |
for kw, country, lang, sort_by, cnt, searched in history:
|
| 799 |
-
html += f'''
|
| 800 |
-
|
| 801 |
-
<td style="padding:
|
| 802 |
-
<td style="padding:
|
| 803 |
-
|
| 804 |
-
<td style="padding:10px; font-size:12px;">{searched[:16].replace("T"," ")}</td>
|
| 805 |
-
</tr>
|
| 806 |
-
'''
|
| 807 |
-
|
| 808 |
-
html += '</tbody></table>'
|
| 809 |
-
return html
|
| 810 |
-
|
| 811 |
-
# 초기 트렌딩
|
| 812 |
-
initial_trending = get_real_trending_keywords("KR", "ko")
|
| 813 |
-
|
| 814 |
-
with gr.Blocks(css=css) as demo:
|
| 815 |
-
gr.HTML('''
|
| 816 |
-
<div style="text-align:center; margin:20px 0;">
|
| 817 |
-
<a href="https://www.humangen.ai" target="_blank">
|
| 818 |
-
<img src="https://img.shields.io/static/v1?label=🏠 HOME&message=HUMANGEN.AI&color=0000ff&labelColor=ffcc00&style=for-the-badge">
|
| 819 |
-
</a>
|
| 820 |
-
</div>
|
| 821 |
-
''')
|
| 822 |
|
| 823 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 824 |
|
| 825 |
-
|
|
|
|
| 826 |
|
| 827 |
with gr.Tabs():
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
with gr.Row():
|
| 833 |
-
keyword = gr.Textbox(label="
|
| 834 |
-
btn = gr.Button("🔍
|
| 835 |
-
refresh = gr.Button("🔄
|
| 836 |
-
|
| 837 |
with gr.Row():
|
| 838 |
-
country = gr.Dropdown(list(COUNTRIES.keys()), value="
|
| 839 |
-
language = gr.Dropdown(list(LANGUAGES.keys()), value="
|
| 840 |
-
sort_by = gr.Dropdown(list(SORT_OPTIONS.keys()), value="
|
| 841 |
-
date_filter = gr.Dropdown(list(DATE_OPTIONS.keys()), value="
|
| 842 |
-
max_results = gr.Slider(10, 1000, value=100, step=10, label="
|
| 843 |
-
|
| 844 |
output = gr.HTML()
|
| 845 |
|
| 846 |
-
|
| 847 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
alerts_out = gr.HTML()
|
| 849 |
|
| 850 |
-
|
| 851 |
-
|
|
|
|
|
|
|
| 852 |
growing_out = gr.HTML()
|
| 853 |
|
| 854 |
-
|
| 855 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
history_out = gr.HTML()
|
| 857 |
|
| 858 |
-
#
|
| 859 |
trending.change(use_trending_keyword, trending, keyword)
|
| 860 |
country.change(update_trending, country, trending)
|
| 861 |
btn.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
|
| 862 |
keyword.submit(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
|
| 863 |
refresh.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
|
| 864 |
|
|
|
|
| 865 |
alerts_btn.click(show_trending_alerts, outputs=alerts_out)
|
| 866 |
growing_btn.click(show_top_growing, outputs=growing_out)
|
| 867 |
history_btn.click(show_search_history, outputs=history_out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
|
| 869 |
demo.launch()
|
| 870 |
|
|
|
|
|
|
| 12 |
youtube = build("youtube", "v3", developerKey=API_KEY)
|
| 13 |
groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
|
| 14 |
|
| 15 |
+
# Global storage
|
| 16 |
+
ai_pick_storage = {"videos": [], "ratings": {}, "timestamp": None}
|
| 17 |
+
|
| 18 |
# ============================================
|
| 19 |
# 🎨 Comic Classic Theme CSS
|
| 20 |
# ============================================
|
|
|
|
| 40 |
text-align: center !important;
|
| 41 |
text-shadow: 4px 4px 0px #FACC15, 6px 6px 0px #1F2937 !important;
|
| 42 |
letter-spacing: 3px !important;
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
.gr-panel, .gr-box, .gr-form, .block, .gr-group {
|
|
|
|
| 56 |
color: #FFFFFF !important;
|
| 57 |
font-family: 'Bangers', cursive !important;
|
| 58 |
font-size: 1.2rem !important;
|
|
|
|
| 59 |
box-shadow: 5px 5px 0px #1F2937 !important;
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
+
.gr-button-primary:hover { background: #2563EB !important; }
|
|
|
|
| 63 |
|
| 64 |
.gr-button-secondary, button.secondary {
|
| 65 |
background: #EF4444 !important;
|
|
|
|
| 69 |
box-shadow: 4px 4px 0px #1F2937 !important;
|
| 70 |
}
|
| 71 |
|
| 72 |
+
textarea, input[type="text"] {
|
| 73 |
background: #FFFFFF !important;
|
| 74 |
border: 3px solid #1F2937 !important;
|
| 75 |
border-radius: 8px !important;
|
|
|
|
| 76 |
font-family: 'Comic Neue', cursive !important;
|
| 77 |
font-weight: 700 !important;
|
| 78 |
}
|
| 79 |
|
| 80 |
+
label { color: #1F2937 !important; font-family: 'Comic Neue', cursive !important; font-weight: 700 !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
::-webkit-scrollbar { width: 12px; }
|
| 83 |
::-webkit-scrollbar-track { background: #FEF9C3; border: 2px solid #1F2937; }
|
| 84 |
::-webkit-scrollbar-thumb { background: #3B82F6; border: 2px solid #1F2937; }
|
|
|
|
|
|
|
| 85 |
::selection { background: #FACC15; color: #1F2937; }
|
| 86 |
+
|
| 87 |
+
.llm-result {
|
| 88 |
+
background: #1F2937 !important;
|
| 89 |
+
color: #10B981 !important;
|
| 90 |
+
border: 3px solid #10B981 !important;
|
| 91 |
+
border-radius: 8px !important;
|
| 92 |
+
padding: 15px !important;
|
| 93 |
+
font-family: 'Courier New', monospace !important;
|
| 94 |
+
white-space: pre-wrap !important;
|
| 95 |
+
}
|
| 96 |
"""
|
| 97 |
|
| 98 |
# DB 초기화
|
| 99 |
def init_db():
|
| 100 |
conn = sqlite3.connect("youtube_data.db")
|
| 101 |
c = conn.cursor()
|
|
|
|
| 102 |
c.execute('''CREATE TABLE IF NOT EXISTS videos (
|
| 103 |
video_id TEXT PRIMARY KEY, title TEXT, channel_id TEXT, channel_name TEXT,
|
| 104 |
thumbnail TEXT, published_at TEXT, first_seen TEXT
|
| 105 |
)''')
|
|
|
|
| 106 |
c.execute('''CREATE TABLE IF NOT EXISTS video_stats (
|
| 107 |
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT, views INTEGER,
|
| 108 |
likes INTEGER, comments INTEGER, recorded_at TEXT
|
| 109 |
)''')
|
|
|
|
| 110 |
c.execute('''CREATE TABLE IF NOT EXISTS channels (
|
| 111 |
channel_id TEXT PRIMARY KEY, channel_name TEXT, first_seen TEXT
|
| 112 |
)''')
|
|
|
|
| 113 |
c.execute('''CREATE TABLE IF NOT EXISTS channel_stats (
|
| 114 |
id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT, subscribers INTEGER, recorded_at TEXT
|
| 115 |
)''')
|
|
|
|
| 116 |
c.execute('''CREATE TABLE IF NOT EXISTS search_history (
|
| 117 |
id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT, country TEXT,
|
| 118 |
language TEXT, sort_by TEXT, results_count INTEGER, searched_at TEXT
|
| 119 |
)''')
|
|
|
|
| 120 |
c.execute('''CREATE TABLE IF NOT EXISTS trending_alerts (
|
| 121 |
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT, alert_type TEXT,
|
| 122 |
old_value INTEGER, new_value INTEGER, change_percent REAL, detected_at TEXT
|
| 123 |
)''')
|
|
|
|
| 124 |
conn.commit()
|
| 125 |
conn.close()
|
| 126 |
|
| 127 |
init_db()
|
| 128 |
|
| 129 |
+
# Country & Language codes
|
| 130 |
COUNTRIES = {
|
| 131 |
+
"Worldwide": ("", ""), "United States": ("US", "en"), "United Kingdom": ("GB", "en"),
|
| 132 |
+
"Canada": ("CA", "en"), "Australia": ("AU", "en"), "Germany": ("DE", "de"),
|
| 133 |
+
"France": ("FR", "fr"), "Japan": ("JP", "ja"), "South Korea": ("KR", "ko"),
|
| 134 |
+
"Brazil": ("BR", "pt"), "Mexico": ("MX", "es"), "Spain": ("ES", "es"),
|
| 135 |
+
"Italy": ("IT", "it"), "Russia": ("RU", "ru"), "India": ("IN", "hi"),
|
| 136 |
+
"Indonesia": ("ID", "id"), "Thailand": ("TH", "th"), "Vietnam": ("VN", "vi"),
|
| 137 |
+
"Philippines": ("PH", "tl"), "Turkey": ("TR", "tr"), "Saudi Arabia": ("SA", "ar"),
|
| 138 |
+
"Egypt": ("EG", "ar"), "South Africa": ("ZA", "en"), "Nigeria": ("NG", "en"),
|
| 139 |
+
"Argentina": ("AR", "es"), "Colombia": ("CO", "es"), "Chile": ("CL", "es"),
|
| 140 |
+
"Poland": ("PL", "pl"), "Netherlands": ("NL", "nl"), "Belgium": ("BE", "nl"),
|
| 141 |
+
"Sweden": ("SE", "sv"), "Norway": ("NO", "no"), "Denmark": ("DK", "da"),
|
| 142 |
+
"Finland": ("FI", "fi"), "Austria": ("AT", "de"), "Switzerland": ("CH", "de"),
|
| 143 |
+
"Portugal": ("PT", "pt"), "Greece": ("GR", "el"), "Czech Republic": ("CZ", "cs"),
|
| 144 |
+
"Romania": ("RO", "ro"), "Ukraine": ("UA", "uk"), "Israel": ("IL", "he"),
|
| 145 |
+
"UAE": ("AE", "ar"), "Singapore": ("SG", "en"), "Malaysia": ("MY", "ms"),
|
| 146 |
+
"Taiwan": ("TW", "zh"), "Hong Kong": ("HK", "zh"), "China": ("CN", "zh"),
|
| 147 |
}
|
| 148 |
|
| 149 |
LANGUAGES = {
|
| 150 |
+
"Auto (by Country)": "", "English": "en", "Spanish": "es", "Portuguese": "pt",
|
| 151 |
+
"French": "fr", "German": "de", "Italian": "it", "Russian": "ru",
|
| 152 |
+
"Japanese": "ja", "Korean": "ko", "Chinese": "zh", "Hindi": "hi",
|
| 153 |
+
"Arabic": "ar", "Turkish": "tr", "Indonesian": "id", "Vietnamese": "vi",
|
| 154 |
+
"Thai": "th", "Dutch": "nl", "Polish": "pl", "Swedish": "sv",
|
| 155 |
+
"Norwegian": "no", "Danish": "da", "Finnish": "fi", "Greek": "el",
|
| 156 |
+
"Hebrew": "he", "Czech": "cs", "Romanian": "ro", "Ukrainian": "uk",
|
| 157 |
+
"Hungarian": "hu", "Filipino": "tl", "Malay": "ms",
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
+
SORT_OPTIONS = {"Most Viewed": "viewCount", "Latest": "date", "Relevance": "relevance", "Top Rated": "rating"}
|
| 161 |
+
DATE_OPTIONS = {"All Time": "", "Today": "today", "This Week": "thisWeek", "This Month": "thisMonth", "This Year": "thisYear"}
|
| 162 |
|
| 163 |
def format_count(count):
|
| 164 |
if count >= 1000000000: return f"{count/1000000000:.1f}B"
|
|
|
|
| 166 |
elif count >= 1000: return f"{count/1000:.1f}K"
|
| 167 |
return str(count)
|
| 168 |
|
| 169 |
+
def call_llm(prompt, max_tokens=2000):
|
| 170 |
+
"""Groq LLM API call"""
|
| 171 |
+
if not groq_client:
|
| 172 |
+
return None
|
|
|
|
| 173 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
completion = groq_client.chat.completions.create(
|
| 175 |
model="openai/gpt-oss-120b",
|
| 176 |
messages=[{"role": "user", "content": prompt}],
|
| 177 |
temperature=0.7,
|
| 178 |
+
max_completion_tokens=max_tokens,
|
| 179 |
top_p=1,
|
| 180 |
stream=True,
|
| 181 |
stop=None
|
| 182 |
)
|
| 183 |
+
result = ""
|
|
|
|
|
|
|
| 184 |
for chunk in completion:
|
| 185 |
if chunk.choices[0].delta.content:
|
| 186 |
+
result += chunk.choices[0].delta.content
|
| 187 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
except Exception as e:
|
| 189 |
+
return f"Error: {e}"
|
|
|
|
| 190 |
|
| 191 |
+
def get_ai_pick_rating(videos_data):
|
| 192 |
+
"""AI Pick rating"""
|
| 193 |
+
global ai_pick_storage
|
|
|
|
| 194 |
if not videos_data:
|
| 195 |
+
return {}
|
| 196 |
|
| 197 |
+
if groq_client:
|
| 198 |
+
try:
|
| 199 |
+
sample = videos_data[:50]
|
| 200 |
+
video_info = "\n".join([
|
| 201 |
+
f"#{i+1}. {v['title'][:40]}, Views:{v['views']}, Likes:{v['likes']}, Subs:{v.get('subs',0)}"
|
| 202 |
+
for i, v in enumerate(sample)
|
| 203 |
+
])
|
| 204 |
+
|
| 205 |
+
prompt = f"""Rate YouTube videos 0-4. 0=None,1=♥,2=⭐,3=⭐⭐,4=⭐⭐⭐
|
| 206 |
+
Consider engagement, viral potential. Format: 1:3,2:2,3:4
|
| 207 |
+
|
| 208 |
+
{video_info}
|
| 209 |
+
|
| 210 |
+
Response (number:rating only):"""
|
| 211 |
+
|
| 212 |
+
result = call_llm(prompt, 1500)
|
| 213 |
+
if result and "Error" not in result:
|
| 214 |
+
ratings = {}
|
| 215 |
+
for idx, rating in re.findall(r'(\d+):(\d)', result):
|
| 216 |
+
ratings[int(idx)-1] = int(rating)
|
| 217 |
+
if len(videos_data) > 50:
|
| 218 |
+
local = calculate_local_rating(videos_data[50:])
|
| 219 |
+
for k, v in local.items():
|
| 220 |
+
ratings[k + 50] = v
|
| 221 |
+
ai_pick_storage = {"videos": videos_data, "ratings": ratings, "timestamp": datetime.now().isoformat()}
|
| 222 |
+
return ratings
|
| 223 |
+
except:
|
| 224 |
+
pass
|
| 225 |
+
|
| 226 |
+
ratings = calculate_local_rating(videos_data)
|
| 227 |
+
ai_pick_storage = {"videos": videos_data, "ratings": ratings, "timestamp": datetime.now().isoformat()}
|
| 228 |
+
return ratings
|
| 229 |
+
|
| 230 |
+
def calculate_local_rating(videos_data):
|
| 231 |
+
ratings = {}
|
| 232 |
+
if not videos_data: return ratings
|
| 233 |
views_list = [v['views'] for v in videos_data if v['views'] > 0]
|
| 234 |
+
if not views_list: return ratings
|
| 235 |
+
avg_views, max_views = sum(views_list)/len(views_list), max(views_list)
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
for i, v in enumerate(videos_data):
|
| 238 |
+
views, likes, comments, subs = v['views'], v['likes'], v['comments'], v.get('subs', 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
score = 0
|
|
|
|
|
|
|
| 240 |
if views > 0:
|
| 241 |
+
score += min(40, (views/max_views)*40)
|
| 242 |
+
score += min(30, ((likes+comments*2)/views)*300)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
if subs > 0 and views > 0:
|
| 244 |
+
score += min(30, (views/subs)*10)
|
| 245 |
elif views > avg_views:
|
| 246 |
score += 15
|
| 247 |
|
|
|
|
| 248 |
if score >= 70: ratings[i] = 4
|
| 249 |
elif score >= 50: ratings[i] = 3
|
| 250 |
elif score >= 30: ratings[i] = 2
|
| 251 |
elif score >= 15: ratings[i] = 1
|
| 252 |
else: ratings[i] = 0
|
|
|
|
| 253 |
return ratings
|
| 254 |
|
| 255 |
def get_rating_display(rating):
|
|
|
|
| 256 |
return {0: "", 1: "♥", 2: "⭐", 3: "⭐⭐", 4: "⭐⭐⭐"}.get(rating, "")
|
| 257 |
|
| 258 |
+
def get_real_trending_keywords(region_code="US", language="en"):
|
| 259 |
try:
|
| 260 |
+
response = youtube.videos().list(part="snippet", chart="mostPopular", regionCode=region_code or "US", maxResults=50).execute()
|
| 261 |
+
keywords, seen = [], set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
for item in response.get("items", []):
|
| 263 |
+
for tag in item["snippet"].get("tags", [])[:3]:
|
| 264 |
+
if tag.lower() not in seen and 2 <= len(tag) <= 20:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
keywords.append(tag)
|
| 266 |
+
seen.add(tag.lower())
|
| 267 |
+
channel = item["snippet"]["channelTitle"]
|
| 268 |
+
if channel.lower() not in seen:
|
| 269 |
keywords.append(channel)
|
| 270 |
seen.add(channel.lower())
|
| 271 |
+
if len(keywords) >= 20: break
|
| 272 |
+
return keywords[:20] if keywords else ["AI","gaming","music","vlog","shorts","news"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
except:
|
| 274 |
+
return ["AI","ChatGPT","gaming","music","vlog","shorts","news","tech"]
|
| 275 |
|
| 276 |
def save_to_db(videos_data, channels_data, keyword, country, language, sort_by):
|
| 277 |
conn = sqlite3.connect("youtube_data.db")
|
| 278 |
c = conn.cursor()
|
| 279 |
now = datetime.now().isoformat()
|
| 280 |
+
c.execute('INSERT INTO search_history VALUES (NULL,?,?,?,?,?,?)', (keyword, country, language, sort_by, len(videos_data), now))
|
|
|
|
|
|
|
|
|
|
| 281 |
for video in videos_data:
|
| 282 |
+
c.execute('INSERT OR IGNORE INTO videos VALUES (?,?,?,?,?,?,?)',
|
| 283 |
(video['video_id'], video['title'], video['channel_id'], video['channel_name'], video['thumbnail'], video['published_at'], now))
|
|
|
|
| 284 |
c.execute('SELECT views FROM video_stats WHERE video_id=? ORDER BY recorded_at DESC LIMIT 1', (video['video_id'],))
|
| 285 |
prev = c.fetchone()
|
| 286 |
+
c.execute('INSERT INTO video_stats VALUES (NULL,?,?,?,?,?)', (video['video_id'], video['views'], video['likes'], video['comments'], now))
|
|
|
|
|
|
|
|
|
|
| 287 |
if prev and prev[0] > 0:
|
| 288 |
change = ((video['views'] - prev[0]) / prev[0]) * 100
|
| 289 |
if change >= 20:
|
| 290 |
+
c.execute('INSERT INTO trending_alerts VALUES (NULL,?,?,?,?,?,?)', (video['video_id'], 'views_surge', prev[0], video['views'], change, now))
|
|
|
|
|
|
|
| 291 |
for ch_id, subs in channels_data.items():
|
| 292 |
+
c.execute('INSERT OR IGNORE INTO channels VALUES (?,?,?)', (ch_id, '', now))
|
| 293 |
if isinstance(subs, int):
|
| 294 |
+
c.execute('INSERT INTO channel_stats VALUES (NULL,?,?,?)', (ch_id, subs, now))
|
|
|
|
| 295 |
conn.commit()
|
| 296 |
conn.close()
|
| 297 |
|
|
|
|
| 299 |
conn = sqlite3.connect("youtube_data.db")
|
| 300 |
c = conn.cursor()
|
| 301 |
stats = {}
|
| 302 |
+
for t, k in [("videos","videos"),("video_stats","stats"),("channels","channels"),("search_history","searches"),("trending_alerts","alerts")]:
|
| 303 |
+
c.execute(f"SELECT COUNT(*) FROM {t}")
|
| 304 |
+
stats[k] = c.fetchone()[0]
|
| 305 |
conn.close()
|
| 306 |
return stats
|
| 307 |
|
| 308 |
def update_trending(country):
|
| 309 |
region, lang = COUNTRIES.get(country, ("", ""))
|
| 310 |
+
return gr.update(choices=get_real_trending_keywords(region or "US", lang or "en"), value=None)
|
|
|
|
| 311 |
|
| 312 |
def use_trending_keyword(kw):
|
| 313 |
return kw if kw else ""
|
| 314 |
|
| 315 |
def search_videos(keyword, country, language, sort_by, date_filter, max_results):
|
| 316 |
if not keyword or not keyword.strip():
|
| 317 |
+
return "⚠️ Please enter a search keyword!", ""
|
| 318 |
|
| 319 |
max_results = int(max_results)
|
| 320 |
+
all_items, next_page = [], None
|
|
|
|
|
|
|
| 321 |
region_code, default_lang = COUNTRIES.get(country, ("", ""))
|
| 322 |
+
lang_code = default_lang if language == "Auto (by Country)" else LANGUAGES.get(language, "")
|
| 323 |
|
| 324 |
params = {"q": keyword, "part": "snippet", "type": "video", "order": SORT_OPTIONS[sort_by]}
|
| 325 |
if region_code: params["regionCode"] = region_code
|
| 326 |
if lang_code: params["relevanceLanguage"] = lang_code
|
| 327 |
+
if date_filter != "All Time":
|
| 328 |
+
deltas = {"Today": 1, "This Week": 7, "This Month": 30, "This Year": 365}
|
| 329 |
+
params["publishedAfter"] = (datetime.utcnow() - timedelta(days=deltas.get(date_filter, 0))).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
while len(all_items) < max_results:
|
| 332 |
params["maxResults"] = min(50, max_results - len(all_items))
|
| 333 |
if next_page: params["pageToken"] = next_page
|
|
|
|
| 334 |
resp = youtube.search().list(**params).execute()
|
| 335 |
items = resp.get("items", [])
|
| 336 |
if not items: break
|
|
|
|
| 337 |
all_items.extend(items)
|
| 338 |
next_page = resp.get("nextPageToken")
|
| 339 |
if not next_page: break
|
| 340 |
|
| 341 |
+
if not all_items: return "No results found.", ""
|
|
|
|
| 342 |
|
| 343 |
video_ids = [item["id"]["videoId"] for item in all_items]
|
| 344 |
channel_ids = list(set([item["snippet"]["channelId"] for item in all_items]))
|
| 345 |
|
|
|
|
| 346 |
video_stats = {}
|
| 347 |
for i in range(0, len(video_ids), 50):
|
| 348 |
+
for v in youtube.videos().list(id=",".join(video_ids[i:i+50]), part="statistics").execute().get("items", []):
|
|
|
|
|
|
|
| 349 |
s = v["statistics"]
|
| 350 |
+
video_stats[v["id"]] = {"views": int(s.get("viewCount", 0)), "likes": int(s.get("likeCount", 0)), "comments": int(s.get("commentCount", 0))}
|
| 351 |
+
|
| 352 |
+
channel_subs, channel_subs_raw = {}, {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
for i in range(0, len(channel_ids), 50):
|
| 354 |
+
for ch in youtube.channels().list(id=",".join(channel_ids[i:i+50]), part="statistics").execute().get("items", []):
|
|
|
|
|
|
|
| 355 |
sub = ch["statistics"].get("subscriberCount", "0")
|
| 356 |
if sub:
|
| 357 |
channel_subs_raw[ch["id"]] = int(sub)
|
| 358 |
channel_subs[ch["id"]] = format_count(int(sub))
|
|
|
|
|
|
|
| 359 |
|
|
|
|
| 360 |
videos_data = []
|
| 361 |
for item in all_items:
|
| 362 |
+
vid, snip = item["id"]["videoId"], item["snippet"]
|
|
|
|
| 363 |
st = video_stats.get(vid, {})
|
| 364 |
videos_data.append({
|
| 365 |
+
"video_id": vid, "title": snip["title"], "channel_id": snip["channelId"],
|
| 366 |
+
"channel_name": snip["channelTitle"], "thumbnail": snip["thumbnails"]["medium"]["url"],
|
| 367 |
+
"published_at": snip["publishedAt"], "views": st.get("views", 0),
|
| 368 |
+
"likes": st.get("likes", 0), "comments": st.get("comments", 0),
|
|
|
|
|
|
|
| 369 |
"subs": channel_subs_raw.get(snip["channelId"], 0),
|
| 370 |
})
|
| 371 |
|
|
|
|
| 372 |
ai_ratings = get_ai_pick_rating(videos_data)
|
|
|
|
|
|
|
| 373 |
save_to_db(videos_data, channel_subs_raw, keyword, country, language, sort_by)
|
| 374 |
|
| 375 |
+
table_id = f"yt_{datetime.now().strftime('%H%M%S%f')}"
|
| 376 |
+
|
| 377 |
html = f'''
|
| 378 |
<style>
|
| 379 |
+
.yt-tbl{{width:100%;border-collapse:collapse;font-family:'Comic Neue',cursive}}
|
| 380 |
+
.yt-tbl th{{background:#EF4444;color:#fff;padding:12px 8px;cursor:pointer;border:2px solid #1F2937;font-family:'Bangers',cursive}}
|
| 381 |
+
.yt-tbl th:hover{{background:#DC2626}}
|
| 382 |
+
.yt-tbl td{{padding:10px 8px;border-bottom:2px solid #1F2937;background:#FFF;vertical-align:middle}}
|
| 383 |
+
.yt-tbl tr:hover td{{background:#FEF9C3}}
|
| 384 |
+
.yt-tbl img{{border-radius:6px;border:2px solid #1F2937}}
|
| 385 |
+
.yt-tbl a{{color:#3B82F6;text-decoration:none;font-weight:700}}
|
| 386 |
+
.yt-tbl a:hover{{color:#EF4444}}
|
| 387 |
+
.ai-pick{{color:#FACC15;text-shadow:1px 1px 0 #1F2937;font-size:1.1rem}}
|
| 388 |
+
.res-hdr{{background:#3B82F6;color:#fff;padding:15px;border-radius:8px;border:3px solid #1F2937;box-shadow:4px 4px 0 #1F2937;margin-bottom:15px;font-family:'Comic Neue',cursive}}
|
| 389 |
+
.qsort{{display:inline-flex;gap:5px;margin-left:10px}}
|
| 390 |
+
.qsort button{{background:#FACC15;border:2px solid #1F2937;border-radius:4px;padding:5px 10px;cursor:pointer;font-weight:700}}
|
| 391 |
+
.qsort button:hover{{background:#1F2937;color:#FACC15}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
</style>
|
| 393 |
+
<div class="res-hdr">
|
| 394 |
+
🎬 <b>{len(all_items)}</b> results | 🔍 "{keyword}" | 🌍 {country}
|
| 395 |
+
<br><span style="font-size:0.9rem">🤖 AI Pick: ♥(avg) ⭐(good) ⭐⭐(great) ⭐⭐⭐(best)</span>
|
| 396 |
+
<div style="margin-top:10px"><span style="font-size:0.9rem">⚡ Sort:</span>
|
| 397 |
+
<span class="qsort">
|
| 398 |
+
<button onclick="sortT_{table_id}(5,'n')">Views</button>
|
| 399 |
+
<button onclick="sortT_{table_id}(6,'n')">Likes</button>
|
| 400 |
+
<button onclick="sortT_{table_id}(4,'n')">Subs</button>
|
| 401 |
+
<button onclick="sortT_{table_id}(8,'n')">AI Pick</button>
|
| 402 |
+
<button onclick="sortT_{table_id}(9,'s')">Date</button>
|
| 403 |
+
</span></div>
|
| 404 |
</div>
|
| 405 |
+
<div style="max-height:700px;overflow-y:auto;border:3px solid #1F2937;border-radius:8px">
|
| 406 |
+
<table class="yt-tbl" id="{table_id}"><thead><tr>
|
| 407 |
+
<th onclick="sortT_{table_id}(0,'n')" style="width:45px">Rank</th>
|
| 408 |
+
<th style="width:130px">Thumb</th>
|
| 409 |
+
<th onclick="sortT_{table_id}(2,'s')">Title</th>
|
| 410 |
+
<th onclick="sortT_{table_id}(3,'s')" style="width:100px">Channel</th>
|
| 411 |
+
<th onclick="sortT_{table_id}(4,'n')" style="width:65px">Subs</th>
|
| 412 |
+
<th onclick="sortT_{table_id}(5,'n')" style="width:70px">Views</th>
|
| 413 |
+
<th onclick="sortT_{table_id}(6,'n')" style="width:55px">Likes</th>
|
| 414 |
+
<th onclick="sortT_{table_id}(7,'n')" style="width:50px">Cmts</th>
|
| 415 |
+
<th onclick="sortT_{table_id}(8,'n')" style="width:70px">AI Pick</th>
|
| 416 |
+
<th onclick="sortT_{table_id}(9,'s')" style="width:85px">Date</th>
|
| 417 |
+
</tr></thead><tbody>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
for i, v in enumerate(videos_data):
|
|
|
|
| 420 |
title = v["title"][:45] + "..." if len(v["title"]) > 45 else v["title"]
|
| 421 |
channel = v["channel_name"][:12] + "..." if len(v["channel_name"]) > 12 else v["channel_name"]
|
| 422 |
+
url, ch_url = f"https://youtube.com/watch?v={v['video_id']}", f"https://youtube.com/channel/{v['channel_id']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
rating = ai_ratings.get(i, 0)
|
| 424 |
+
rank_style = "color:#FFD700;font-size:1.3rem" if i==0 else ("color:#C0C0C0;font-size:1.2rem" if i==1 else ("color:#CD7F32;font-size:1.2rem" if i==2 else ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
+
html += f'''<tr>
|
| 427 |
+
<td style="text-align:center;font-family:'Bangers',cursive;color:#EF4444;{rank_style}" data-v="{i+1}">{i+1}</td>
|
| 428 |
+
<td><a href="{url}" target="_blank"><img src="{v['thumbnail']}" width="120" height="68"></a></td>
|
| 429 |
+
<td data-v="{v['title'][:80]}"><a href="{url}" target="_blank" title="{v['title']}">{title}</a></td>
|
|
|
|
| 430 |
<td data-v="{v['channel_name']}"><a href="{ch_url}" target="_blank">{channel}</a></td>
|
| 431 |
+
<td data-v="{v['subs']}">{format_count(v['subs'])}</td>
|
| 432 |
+
<td data-v="{v['views']}" style="color:#3B82F6;font-weight:700">{format_count(v['views'])}</td>
|
| 433 |
+
<td data-v="{v['likes']}">{format_count(v['likes'])}</td>
|
| 434 |
+
<td data-v="{v['comments']}">{format_count(v['comments'])}</td>
|
| 435 |
+
<td class="ai-pick" data-v="{rating}">{get_rating_display(rating)}</td>
|
| 436 |
+
<td data-v="{v['published_at'][:10]}">{v['published_at'][:10]}</td>
|
| 437 |
+
</tr>'''
|
| 438 |
+
|
| 439 |
+
html += f'''</tbody></table></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
<script>
|
| 441 |
+
var ss_{table_id}={{}};
|
| 442 |
+
function sortT_{table_id}(c,t){{
|
| 443 |
+
var tb=document.getElementById('{table_id}'),tbody=tb.querySelector('tbody'),rows=Array.from(tbody.querySelectorAll('tr'));
|
| 444 |
+
var asc=ss_{table_id}[c]!=='asc';ss_{table_id}={{}};ss_{table_id}[c]=asc?'asc':'desc';
|
| 445 |
+
rows.sort((a,b)=>{{
|
| 446 |
+
var av=a.cells[c].dataset.v||a.cells[c].textContent,bv=b.cells[c].dataset.v||b.cells[c].textContent;
|
| 447 |
+
if(t==='n'){{av=parseFloat(av)||0;bv=parseFloat(bv)||0;return asc?av-bv:bv-av}}
|
| 448 |
+
return asc?av.localeCompare(bv):bv.localeCompare(av)
|
| 449 |
+
}});
|
| 450 |
+
rows.forEach(r=>tbody.appendChild(r))
|
| 451 |
+
}}
|
| 452 |
+
</script>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
| 454 |
stats = get_db_stats()
|
| 455 |
+
return html, f"📊 DB: Videos {stats['videos']} | Records {stats['stats']} | Channels {stats['channels']} | Searches {stats['searches']}"
|
|
|
|
|
|
|
| 456 |
|
| 457 |
def show_trending_alerts():
|
| 458 |
+
"""🔥 TRENDING - Videos with 20%+ SUDDEN view increase (Event-based alerts)"""
|
| 459 |
conn = sqlite3.connect("youtube_data.db")
|
| 460 |
c = conn.cursor()
|
| 461 |
+
c.execute('''SELECT ta.video_id, v.title, v.channel_name, v.thumbnail, ta.old_value, ta.new_value, ta.change_percent, ta.detected_at
|
| 462 |
+
FROM trending_alerts ta JOIN videos v ON ta.video_id = v.video_id ORDER BY ta.detected_at DESC LIMIT 30''')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
alerts = c.fetchall()
|
| 464 |
conn.close()
|
| 465 |
|
| 466 |
+
info_box = '''
|
| 467 |
+
<div style="background:linear-gradient(135deg,#EF4444,#DC2626);color:#fff;padding:20px;border:3px solid #1F2937;border-radius:8px;margin-bottom:20px;font-family:'Comic Neue',cursive">
|
| 468 |
+
<h2 style="font-family:'Bangers',cursive;margin:0 0 10px 0">🔥 TRENDING - Sudden Surge Detection</h2>
|
| 469 |
+
<table style="width:100%;color:#fff;font-size:14px">
|
| 470 |
+
<tr><td style="width:120px"><b>📌 What is this?</b></td><td>Videos with <b>20%+ sudden view increase</b> compared to previous check</td></tr>
|
| 471 |
+
<tr><td><b>🎯 Purpose</b></td><td>Catch <b>viral moments</b> and <b>breaking content</b> in real-time</td></tr>
|
| 472 |
+
<tr><td><b>⏱️ Trigger</b></td><td>When same video is searched again and views jumped 20%+</td></tr>
|
| 473 |
+
<tr><td><b>💡 Best for</b></td><td>Finding videos going viral RIGHT NOW, news events, trending moments</td></tr>
|
| 474 |
+
</table>
|
| 475 |
+
</div>'''
|
| 476 |
+
|
| 477 |
if not alerts:
|
| 478 |
try:
|
| 479 |
+
resp = youtube.videos().list(part="snippet,statistics", chart="mostPopular", regionCode="US", maxResults=20).execute()
|
| 480 |
+
html = info_box + '<p style="font-family:Comic Neue,cursive;margin-bottom:15px">📢 No surge alerts yet. Showing current popular videos. <b>Run searches multiple times</b> to detect surges!</p><div style="display:flex;flex-wrap:wrap;gap:15px">'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
for i, item in enumerate(resp.get("items", [])[:20], 1):
|
| 482 |
+
snip, stats = item["snippet"], item["statistics"]
|
| 483 |
+
title = snip["title"][:35] + "..." if len(snip["title"]) > 35 else snip["title"]
|
| 484 |
+
html += f'''<div style="width:200px;background:#FFF;border:3px solid #1F2937;border-radius:8px;padding:12px;box-shadow:4px 4px 0 #1F2937;font-family:'Comic Neue',cursive">
|
| 485 |
+
<a href="https://youtube.com/watch?v={item['id']}" target="_blank"><img src="{snip['thumbnails']['medium']['url']}" style="width:100%;border-radius:5px;border:2px solid #1F2937"></a>
|
| 486 |
+
<p style="margin:10px 0 5px;font-size:12px;font-weight:700">{i}. {title}</p>
|
| 487 |
+
<p style="margin:0;font-size:15px;color:#EF4444;font-weight:700;font-family:'Bangers',cursive">👀 {format_count(int(stats.get('viewCount',0)))}</p>
|
| 488 |
+
</div>'''
|
| 489 |
+
return html + '</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
except Exception as e:
|
| 491 |
+
return info_box + f"<p>Error: {e}</p>"
|
| 492 |
+
|
| 493 |
+
html = info_box + '<div style="display:flex;flex-wrap:wrap;gap:15px">'
|
| 494 |
+
for vid, title, channel, thumb, old_v, new_v, pct, detected in alerts:
|
| 495 |
+
title = title[:30] + "..." if len(title) > 30 else title
|
| 496 |
+
html += f'''<div style="width:200px;background:#FFF;border:3px solid #EF4444;border-radius:8px;padding:12px;box-shadow:4px 4px 0 #1F2937;font-family:'Comic Neue',cursive">
|
| 497 |
+
<a href="https://youtube.com/watch?v={vid}" target="_blank"><img src="{thumb}" style="width:100%;border-radius:5px;border:2px solid #1F2937"></a>
|
| 498 |
+
<p style="margin:10px 0 5px;font-size:12px;font-weight:700">{title}</p>
|
| 499 |
+
<p style="margin:0;font-size:11px;color:#666">{channel[:20]}</p>
|
| 500 |
+
<p style="margin:5px 0 0;font-size:20px;color:#EF4444;font-weight:700;font-family:'Bangers',cursive">🔥 +{pct:.1f}%</p>
|
| 501 |
+
<p style="margin:2px 0 0;font-size:11px;color:#888">{format_count(old_v)} → {format_count(new_v)}</p>
|
| 502 |
+
</div>'''
|
| 503 |
+
return html + '</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
def show_top_growing():
|
| 506 |
+
"""📈 TOP GROWING - Highest growth rate over 48 hours (Ranking-based)"""
|
| 507 |
conn = sqlite3.connect("youtube_data.db")
|
| 508 |
c = conn.cursor()
|
| 509 |
cutoff = (datetime.now() - timedelta(hours=48)).isoformat()
|
| 510 |
+
c.execute('''SELECT v.video_id, v.title, v.channel_name, v.thumbnail,
|
|
|
|
|
|
|
| 511 |
MIN(vs.views) as min_v, MAX(vs.views) as max_v,
|
| 512 |
((MAX(vs.views) - MIN(vs.views)) * 100.0 / MIN(vs.views)) as growth
|
| 513 |
+
FROM videos v JOIN video_stats vs ON v.video_id = vs.video_id
|
| 514 |
+
WHERE vs.recorded_at > ? GROUP BY v.video_id HAVING min_v > 0 AND max_v > min_v
|
| 515 |
+
ORDER BY growth DESC LIMIT 20''', (cutoff,))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
results = c.fetchall()
|
| 517 |
conn.close()
|
| 518 |
|
| 519 |
+
info_box = '''
|
| 520 |
+
<div style="background:linear-gradient(135deg,#3B82F6,#2563EB);color:#fff;padding:20px;border:3px solid #1F2937;border-radius:8px;margin-bottom:20px;font-family:'Comic Neue',cursive">
|
| 521 |
+
<h2 style="font-family:'Bangers',cursive;margin:0 0 10px 0">📈 TOP GROWING - 48h Growth Champions</h2>
|
| 522 |
+
<table style="width:100%;color:#fff;font-size:14px">
|
| 523 |
+
<tr><td style="width:120px"><b>📌 What is this?</b></td><td>Videos ranked by <b>highest growth RATE over 48 hours</b></td></tr>
|
| 524 |
+
<tr><td><b>🎯 Purpose</b></td><td>Find <b>consistently rising</b> content with sustained momentum</td></tr>
|
| 525 |
+
<tr><td><b>📊 Calculation</b></td><td>(Max Views - Min Views) / Min Views × 100% over 48h period</td></tr>
|
| 526 |
+
<tr><td><b>💡 Best for</b></td><td>Discovering steady performers, evergreen content, reliable trends</td></tr>
|
| 527 |
+
</table>
|
| 528 |
+
</div>'''
|
| 529 |
+
|
| 530 |
if not results:
|
| 531 |
try:
|
| 532 |
+
resp = youtube.videos().list(part="snippet,statistics", chart="mostPopular", regionCode="US", maxResults=20).execute()
|
| 533 |
+
html = info_box + '<p style="font-family:Comic Neue,cursive;margin-bottom:15px">📢 No growth data yet. <b>Run searches over time</b> to accumulate data. Showing current popular:</p><div style="display:flex;flex-wrap:wrap;gap:15px">'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
for i, item in enumerate(resp.get("items", [])[:20], 1):
|
| 535 |
+
snip, stats = item["snippet"], item["statistics"]
|
| 536 |
+
views, likes = int(stats.get("viewCount", 0)), int(stats.get("likeCount", 0))
|
|
|
|
|
|
|
| 537 |
engagement = (likes / views * 100) if views > 0 else 0
|
| 538 |
+
title = snip["title"][:30] + "..." if len(snip["title"]) > 30 else snip["title"]
|
| 539 |
+
html += f'''<div style="width:200px;background:#FFF;border:3px solid #1F2937;border-radius:8px;padding:12px;box-shadow:4px 4px 0 #1F2937;font-family:'Comic Neue',cursive">
|
| 540 |
+
<a href="https://youtube.com/watch?v={item['id']}" target="_blank"><img src="{snip['thumbnails']['medium']['url']}" style="width:100%;border-radius:5px;border:2px solid #1F2937"></a>
|
| 541 |
+
<p style="margin:10px 0 5px;font-size:12px;font-weight:700">{i}. {title}</p>
|
| 542 |
+
<p style="margin:0;font-size:11px;color:#666">{snip['channelTitle'][:18]}</p>
|
| 543 |
+
<p style="margin:5px 0 0;font-size:15px;color:#3B82F6;font-weight:700">👀 {format_count(views)}</p>
|
| 544 |
+
<p style="margin:2px 0 0;font-size:12px;color:#EF4444">❤️ {engagement:.2f}%</p>
|
| 545 |
+
</div>'''
|
| 546 |
+
return html + '</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
except Exception as e:
|
| 548 |
+
return info_box + f"<p>Error: {e}</p>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
|
| 550 |
+
html = info_box + '<div style="display:flex;flex-wrap:wrap;gap:15px">'
|
| 551 |
for i, (vid, title, channel, thumb, min_v, max_v, growth) in enumerate(results, 1):
|
| 552 |
+
title = title[:30] + "..." if len(title) > 30 else title
|
| 553 |
+
html += f'''<div style="width:200px;background:#FFF;border:3px solid #3B82F6;border-radius:8px;padding:12px;box-shadow:4px 4px 0 #1F2937;font-family:'Comic Neue',cursive">
|
| 554 |
+
<a href="https://youtube.com/watch?v={vid}" target="_blank"><img src="{thumb}" style="width:100%;border-radius:5px;border:2px solid #1F2937"></a>
|
| 555 |
+
<p style="margin:10px 0 5px;font-size:12px;font-weight:700">{i}. {title}</p>
|
| 556 |
+
<p style="margin:0;font-size:11px;color:#666">{channel[:18]}</p>
|
| 557 |
+
<p style="margin:5px 0 0;font-size:20px;color:#3B82F6;font-weight:700;font-family:'Bangers',cursive">📈 +{growth:.1f}%</p>
|
| 558 |
+
<p style="margin:2px 0 0;font-size:11px;color:#888">{format_count(int(min_v))} → {format_count(int(max_v))}</p>
|
| 559 |
+
</div>'''
|
| 560 |
+
return html + '</div>'
|
| 561 |
+
|
| 562 |
+
def show_ai_picks():
|
| 563 |
+
"""⭐ AI Pick - Top rated videos with LLM analysis"""
|
| 564 |
+
global ai_pick_storage
|
| 565 |
+
if not ai_pick_storage["videos"]:
|
| 566 |
+
return '<div style="background:#FACC15;padding:20px;border:3px solid #1F2937;border-radius:8px;text-align:center;font-family:Comic Neue,cursive"><h2 style="font-family:Bangers,cursive">⭐ AI PICK - No Data Yet</h2><p>Run a search first to see AI-curated picks!</p></div>'
|
| 567 |
+
|
| 568 |
+
videos, ratings = ai_pick_storage["videos"], ai_pick_storage["ratings"]
|
| 569 |
+
top_picks = [(i, v, ratings.get(i, 0)) for i, v in enumerate(videos) if ratings.get(i, 0) >= 3]
|
| 570 |
+
top_picks.sort(key=lambda x: (-x[2], -x[1]['views']))
|
| 571 |
+
|
| 572 |
+
analysis = ""
|
| 573 |
+
if groq_client and top_picks:
|
| 574 |
+
info = "\n".join([f"- {v['title'][:50]} (Views:{format_count(v['views'])})" for _, v, _ in top_picks[:5]])
|
| 575 |
+
result = call_llm(f"Analyze these top YouTube videos in 3-4 sentences:\n{info}\n\nProvide: 1) Common theme 2) Why popular 3) Content opportunity", 500)
|
| 576 |
+
if result and "Error" not in result:
|
| 577 |
+
analysis = f'<div style="background:#1F2937;color:#10B981;padding:15px;border:3px solid #10B981;border-radius:8px;margin:15px 0;font-family:Courier New,monospace"><h4 style="color:#FACC15;margin:0 0 10px">🤖 AI ANALYSIS</h4><p style="margin:0;font-size:14px;line-height:1.6">{result}</p></div>'
|
| 578 |
+
|
| 579 |
+
html = f'''<div style="background:linear-gradient(135deg,#FACC15,#EF4444);padding:20px;border:3px solid #1F2937;border-radius:8px;margin-bottom:15px">
|
| 580 |
+
<h2 style="font-family:Bangers,cursive;color:#1F2937;margin:0;text-shadow:2px 2px 0 #FFF">⭐ AI PICK - TOP RECOMMENDATIONS</h2>
|
| 581 |
+
<p style="color:#1F2937;margin:5px 0 0;font-family:Comic Neue,cursive;font-weight:700">Showing {len(top_picks)} videos with ⭐⭐+ rating</p>
|
| 582 |
+
</div>{analysis}<div style="display:flex;flex-wrap:wrap;gap:20px">'''
|
| 583 |
+
|
| 584 |
+
for idx, (_, v, rating) in enumerate(top_picks[:30], 1):
|
| 585 |
+
border = "#FFD700" if rating == 4 else "#C0C0C0"
|
| 586 |
+
title = v["title"][:40] + "..." if len(v["title"]) > 40 else v["title"]
|
| 587 |
+
html += f'''<div style="width:220px;background:#FFF;border:4px solid {border};border-radius:12px;padding:15px;box-shadow:6px 6px 0 #1F2937;font-family:Comic Neue,cursive">
|
| 588 |
+
<div style="position:relative"><a href="https://youtube.com/watch?v={v['video_id']}" target="_blank"><img src="{v['thumbnail']}" style="width:100%;border-radius:8px;border:2px solid #1F2937"></a>
|
| 589 |
+
<span style="position:absolute;top:5px;right:5px;background:#1F2937;color:#FACC15;padding:3px 8px;border-radius:4px;font-family:Bangers,cursive;font-size:18px">{get_rating_display(rating)}</span></div>
|
| 590 |
+
<p style="margin:12px 0 5px;font-size:13px;font-weight:700">{idx}. {title}</p>
|
| 591 |
+
<p style="margin:0;font-size:11px;color:#666">{v["channel_name"][:20]}</p>
|
| 592 |
+
<div style="display:flex;justify-content:space-between;margin-top:10px;font-size:12px">
|
| 593 |
+
<span style="color:#3B82F6;font-weight:700">👀 {format_count(v['views'])}</span>
|
| 594 |
+
<span style="color:#EF4444">❤️ {format_count(v['likes'])}</span>
|
| 595 |
+
</div></div>'''
|
| 596 |
+
|
| 597 |
+
return html + '</div>' if top_picks else html + '<p style="text-align:center;padding:30px">No ⭐⭐+ rated videos. Try different keywords!</p></div>'
|
| 598 |
+
|
| 599 |
+
# ============================================
|
| 600 |
+
# 🤖 LLM Analysis Functions
|
| 601 |
+
# ============================================
|
| 602 |
+
|
| 603 |
+
def analyze_keyword_suggest(keyword):
|
| 604 |
+
"""Keyword Suggestion based on input"""
|
| 605 |
+
if not keyword:
|
| 606 |
+
return "⚠️ Please enter a keyword first!"
|
| 607 |
+
if not groq_client:
|
| 608 |
+
return "⚠️ LLM API not configured. Set GROQ_API_KEY environment variable."
|
| 609 |
+
|
| 610 |
+
prompt = f"""You are a YouTube SEO expert. Given the keyword "{keyword}", suggest 15 related keywords that could perform well on YouTube.
|
| 611 |
+
|
| 612 |
+
For each keyword, provide:
|
| 613 |
+
1. The keyword
|
| 614 |
+
2. Estimated search volume (High/Medium/Low)
|
| 615 |
+
3. Competition level (High/Medium/Low)
|
| 616 |
+
4. Content type suggestion (Tutorial/Review/Vlog/etc)
|
| 617 |
+
|
| 618 |
+
Format as a clear list. Be specific and actionable."""
|
| 619 |
+
|
| 620 |
+
result = call_llm(prompt, 1500)
|
| 621 |
+
return result if result else "Error generating suggestions"
|
| 622 |
+
|
| 623 |
+
def analyze_trend_prediction(keyword):
|
| 624 |
+
"""Trend Prediction based on keyword"""
|
| 625 |
+
if not keyword:
|
| 626 |
+
return "⚠️ Please enter a keyword first!"
|
| 627 |
+
if not groq_client:
|
| 628 |
+
return "⚠️ LLM API not configured."
|
| 629 |
+
|
| 630 |
+
prompt = f"""You are a trend analyst. Analyze the YouTube trend potential for "{keyword}".
|
| 631 |
+
|
| 632 |
+
Provide:
|
| 633 |
+
1. **Current Trend Status**: Is it rising, stable, or declining?
|
| 634 |
+
2. **Peak Season**: When does this topic typically peak? (months/events)
|
| 635 |
+
3. **6-Month Forecast**: Predicted trajectory with reasoning
|
| 636 |
+
4. **Risk Factors**: What could affect this trend negatively?
|
| 637 |
+
5. **Opportunity Windows**: Best times to publish content
|
| 638 |
+
6. **Related Emerging Topics**: What related topics are gaining momentum?
|
| 639 |
+
|
| 640 |
+
Be specific with data-driven reasoning."""
|
| 641 |
+
|
| 642 |
+
result = call_llm(prompt, 1500)
|
| 643 |
+
return result if result else "Error generating prediction"
|
| 644 |
+
|
| 645 |
+
def analyze_content_ideas(keyword):
|
| 646 |
+
"""Content Ideas generation"""
|
| 647 |
+
if not keyword:
|
| 648 |
+
return "⚠️ Please enter a keyword first!"
|
| 649 |
+
if not groq_client:
|
| 650 |
+
return "⚠️ LLM API not configured."
|
| 651 |
+
|
| 652 |
+
prompt = f"""You are a creative YouTube content strategist. Generate 10 unique video ideas for the topic "{keyword}".
|
| 653 |
+
|
| 654 |
+
For each idea provide:
|
| 655 |
+
1. **Video Title** (clickable, SEO-optimized)
|
| 656 |
+
2. **Hook** (first 5 seconds script)
|
| 657 |
+
3. **Format** (Tutorial/Listicle/Challenge/Story/etc)
|
| 658 |
+
4. **Target Length** (Short <1min / Medium 5-10min / Long 15+min)
|
| 659 |
+
5. **Thumbnail Concept** (brief visual description)
|
| 660 |
+
6. **Viral Potential** (1-10 score with reason)
|
| 661 |
+
|
| 662 |
+
Make ideas diverse, creative, and actionable."""
|
| 663 |
+
|
| 664 |
+
result = call_llm(prompt, 2000)
|
| 665 |
+
return result if result else "Error generating ideas"
|
| 666 |
+
|
| 667 |
+
def analyze_channel(channel_url):
|
| 668 |
+
"""Channel Analysis"""
|
| 669 |
+
if not channel_url:
|
| 670 |
+
return "⚠️ Please enter a channel name or URL!"
|
| 671 |
+
if not groq_client:
|
| 672 |
+
return "⚠️ LLM API not configured."
|
| 673 |
+
|
| 674 |
+
# Extract channel info if possible
|
| 675 |
+
channel_name = channel_url.split("/")[-1] if "/" in channel_url else channel_url
|
| 676 |
+
|
| 677 |
+
prompt = f"""You are a YouTube channel growth consultant. Analyze a channel focused on or named "{channel_name}".
|
| 678 |
+
|
| 679 |
+
Provide a comprehensive analysis:
|
| 680 |
+
|
| 681 |
+
1. **Niche Assessment**
|
| 682 |
+
- Target audience profile
|
| 683 |
+
- Content saturation level
|
| 684 |
+
- Growth potential in this niche
|
| 685 |
+
|
| 686 |
+
2. **Content Strategy Recommendations**
|
| 687 |
+
- Optimal posting frequency
|
| 688 |
+
- Best content formats for this niche
|
| 689 |
+
- Trending topics to cover
|
| 690 |
+
|
| 691 |
+
3. **Growth Tactics**
|
| 692 |
+
- Quick wins (immediate actions)
|
| 693 |
+
- Medium-term strategies (1-3 months)
|
| 694 |
+
- Long-term positioning (6-12 months)
|
| 695 |
+
|
| 696 |
+
4. **Monetization Opportunities**
|
| 697 |
+
- Ad revenue potential
|
| 698 |
+
- Sponsorship opportunities
|
| 699 |
+
- Alternative revenue streams
|
| 700 |
+
|
| 701 |
+
5. **Competitive Advantages to Build**
|
| 702 |
+
- Unique value propositions
|
| 703 |
+
- Differentiation strategies
|
| 704 |
+
|
| 705 |
+
Be specific and actionable."""
|
| 706 |
+
|
| 707 |
+
result = call_llm(prompt, 2000)
|
| 708 |
+
return result if result else "Error analyzing channel"
|
| 709 |
+
|
| 710 |
+
def analyze_competitor(my_channel, competitor):
|
| 711 |
+
"""Competitor Analysis"""
|
| 712 |
+
if not my_channel or not competitor:
|
| 713 |
+
return "⚠️ Please enter both your channel/topic and competitor!"
|
| 714 |
+
if not groq_client:
|
| 715 |
+
return "⚠️ LLM API not configured."
|
| 716 |
+
|
| 717 |
+
prompt = f"""You are a competitive intelligence analyst for YouTube.
|
| 718 |
+
Compare "{my_channel}" against competitor "{competitor}".
|
| 719 |
+
|
| 720 |
+
Provide detailed competitive analysis:
|
| 721 |
+
|
| 722 |
+
1. **Positioning Comparison**
|
| 723 |
+
- Content focus differences
|
| 724 |
+
- Target audience overlap
|
| 725 |
+
- Brand positioning
|
| 726 |
+
|
| 727 |
+
2. **Content Gap Analysis**
|
| 728 |
+
- Topics competitor covers that you don't
|
| 729 |
+
- Your unique content opportunities
|
| 730 |
+
- Underserved audience needs
|
| 731 |
+
|
| 732 |
+
3. **Performance Benchmarks**
|
| 733 |
+
- Typical metrics to aim for
|
| 734 |
+
- Engagement rate targets
|
| 735 |
+
- Growth rate expectations
|
| 736 |
+
|
| 737 |
+
4. **Competitive Advantages**
|
| 738 |
+
- Competitor's strengths to learn from
|
| 739 |
+
- Competitor's weaknesses to exploit
|
| 740 |
+
- Your potential differentiators
|
| 741 |
+
|
| 742 |
+
5. **Action Plan**
|
| 743 |
+
- Immediate actions (this week)
|
| 744 |
+
- Short-term goals (this month)
|
| 745 |
+
- Strategic moves (this quarter)
|
| 746 |
+
|
| 747 |
+
6. **Content Ideas to Outperform**
|
| 748 |
+
- 5 specific video ideas that could beat competitor content
|
| 749 |
+
|
| 750 |
+
Be strategic and actionable."""
|
| 751 |
+
|
| 752 |
+
result = call_llm(prompt, 2000)
|
| 753 |
+
return result if result else "Error analyzing competitor"
|
| 754 |
|
| 755 |
def show_search_history():
|
| 756 |
conn = sqlite3.connect("youtube_data.db")
|
|
|
|
| 760 |
conn.close()
|
| 761 |
|
| 762 |
if not history:
|
| 763 |
+
return "<p style='font-family:Comic Neue,cursive'>No search history yet.</p>"
|
| 764 |
|
| 765 |
+
html = '''<div style="background:#1F2937;color:#FACC15;padding:15px;border:3px solid #1F2937;border-radius:8px;margin-bottom:15px">
|
| 766 |
+
<h3 style="font-family:Bangers,cursive;margin:0">🕐 SEARCH HISTORY</h3></div>
|
| 767 |
+
<table style="width:100%;border-collapse:collapse;font-family:Comic Neue,cursive">
|
| 768 |
+
<thead><tr style="background:#FACC15;color:#1F2937">
|
| 769 |
+
<th style="padding:12px;border:2px solid #1F2937">Keyword</th><th style="padding:12px">Country</th>
|
| 770 |
+
<th style="padding:12px">Language</th><th style="padding:12px">Sort</th>
|
| 771 |
+
<th style="padding:12px">Results</th><th style="padding:12px">Time</th>
|
| 772 |
+
</tr></thead><tbody>'''
|
|
|
|
|
|
|
|
|
|
| 773 |
|
| 774 |
for kw, country, lang, sort_by, cnt, searched in history:
|
| 775 |
+
html += f'''<tr style="background:#FFF;border-bottom:2px solid #1F2937">
|
| 776 |
+
<td style="padding:12px;font-weight:700">{kw}</td><td style="padding:12px">{country}</td>
|
| 777 |
+
<td style="padding:12px">{lang}</td><td style="padding:12px">{sort_by}</td>
|
| 778 |
+
<td style="padding:12px">{cnt}</td><td style="padding:12px;font-size:12px">{searched[:16].replace("T"," ")}</td>
|
| 779 |
+
</tr>'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
|
| 781 |
+
return html + '</tbody></table>'
|
| 782 |
+
|
| 783 |
+
# Initial trending
|
| 784 |
+
initial_trending = get_real_trending_keywords("US", "en")
|
| 785 |
+
|
| 786 |
+
# ============================================
|
| 787 |
+
# 🎨 Gradio UI
|
| 788 |
+
# ============================================
|
| 789 |
+
with gr.Blocks(css=css, title="YouTube Trend Analyzer") as demo:
|
| 790 |
+
gr.HTML('''<div style="text-align:center;margin:20px 0">
|
| 791 |
+
<a href="https://www.humangen.ai" target="_blank">
|
| 792 |
+
<img src="https://img.shields.io/static/v1?label=🏠 HOME&message=HUMANGEN.AI&color=0000ff&labelColor=ffcc00&style=for-the-badge">
|
| 793 |
+
</a></div>''')
|
| 794 |
|
| 795 |
+
gr.Markdown("# 🎬 YOUTUBE TREND ANALYZER 📊", elem_classes="header-text")
|
| 796 |
+
db_stats = gr.Markdown("📊 Loading DB stats...")
|
| 797 |
|
| 798 |
with gr.Tabs():
|
| 799 |
+
# Tab 1: Search
|
| 800 |
+
with gr.Tab("🔍 Search"):
|
| 801 |
+
gr.Markdown("### 🔥 Trending Keywords")
|
| 802 |
+
trending = gr.Radio(choices=initial_trending, label="Click to auto-fill", interactive=True)
|
| 803 |
with gr.Row():
|
| 804 |
+
keyword = gr.Textbox(label="Search Keyword", placeholder="Enter keyword...", scale=3)
|
| 805 |
+
btn = gr.Button("🔍 SEARCH", variant="primary", scale=1)
|
| 806 |
+
refresh = gr.Button("🔄 Refresh", variant="secondary", scale=1)
|
|
|
|
| 807 |
with gr.Row():
|
| 808 |
+
country = gr.Dropdown(list(COUNTRIES.keys()), value="United States", label="Country")
|
| 809 |
+
language = gr.Dropdown(list(LANGUAGES.keys()), value="Auto (by Country)", label="Language")
|
| 810 |
+
sort_by = gr.Dropdown(list(SORT_OPTIONS.keys()), value="Most Viewed", label="Sort By")
|
| 811 |
+
date_filter = gr.Dropdown(list(DATE_OPTIONS.keys()), value="All Time", label="Period")
|
| 812 |
+
max_results = gr.Slider(10, 1000, value=100, step=10, label="Max Results")
|
|
|
|
| 813 |
output = gr.HTML()
|
| 814 |
|
| 815 |
+
# Tab 2: AI Pick
|
| 816 |
+
with gr.Tab("⭐ AI Pick"):
|
| 817 |
+
gr.Markdown("### 🤖 AI-Curated Top Recommendations (⭐⭐ and above)")
|
| 818 |
+
pick_btn = gr.Button("🔄 Refresh AI Picks", variant="primary")
|
| 819 |
+
pick_out = gr.HTML()
|
| 820 |
+
|
| 821 |
+
# Tab 3: Trending
|
| 822 |
+
with gr.Tab("🔥 Trending"):
|
| 823 |
+
gr.Markdown("### 🔥 Sudden Surge Detection (20%+ view increase)")
|
| 824 |
+
alerts_btn = gr.Button("🔄 Refresh", variant="primary")
|
| 825 |
alerts_out = gr.HTML()
|
| 826 |
|
| 827 |
+
# Tab 4: Top Growing
|
| 828 |
+
with gr.Tab("📈 Top Growing"):
|
| 829 |
+
gr.Markdown("### 📈 48-Hour Growth Champions (Highest growth rate)")
|
| 830 |
+
growing_btn = gr.Button("🔄 Refresh", variant="primary")
|
| 831 |
growing_out = gr.HTML()
|
| 832 |
|
| 833 |
+
# Tab 5: AI Tools
|
| 834 |
+
with gr.Tab("🤖 AI Tools"):
|
| 835 |
+
gr.Markdown("### 🧠 LLM-Powered Analysis Tools (GPT-OSS-120B)")
|
| 836 |
+
|
| 837 |
+
with gr.Tabs():
|
| 838 |
+
# Keyword Suggest
|
| 839 |
+
with gr.Tab("🏷️ Keyword Suggest"):
|
| 840 |
+
gr.Markdown("**Generate related keywords with SEO insights**")
|
| 841 |
+
kw_input = gr.Textbox(label="Enter base keyword", placeholder="e.g., Python tutorial")
|
| 842 |
+
kw_btn = gr.Button("🔍 Generate Keywords", variant="primary")
|
| 843 |
+
kw_output = gr.Textbox(label="Suggested Keywords", lines=20, elem_classes="llm-result")
|
| 844 |
+
|
| 845 |
+
# Trend Prediction
|
| 846 |
+
with gr.Tab("🔮 Trend Prediction"):
|
| 847 |
+
gr.Markdown("**Predict future trend trajectory**")
|
| 848 |
+
tp_input = gr.Textbox(label="Enter topic/keyword", placeholder="e.g., AI tools")
|
| 849 |
+
tp_btn = gr.Button("🔮 Predict Trend", variant="primary")
|
| 850 |
+
tp_output = gr.Textbox(label="Trend Analysis", lines=20, elem_classes="llm-result")
|
| 851 |
+
|
| 852 |
+
# Content Ideas
|
| 853 |
+
with gr.Tab("💡 Content Ideas"):
|
| 854 |
+
gr.Markdown("**Generate creative video ideas**")
|
| 855 |
+
ci_input = gr.Textbox(label="Enter topic", placeholder="e.g., Home workout")
|
| 856 |
+
ci_btn = gr.Button("💡 Generate Ideas", variant="primary")
|
| 857 |
+
ci_output = gr.Textbox(label="Content Ideas", lines=25, elem_classes="llm-result")
|
| 858 |
+
|
| 859 |
+
# Channel Analysis
|
| 860 |
+
with gr.Tab("📊 Channel Analysis"):
|
| 861 |
+
gr.Markdown("**Get growth strategy for your channel**")
|
| 862 |
+
ca_input = gr.Textbox(label="Enter channel name or niche", placeholder="e.g., Tech reviews")
|
| 863 |
+
ca_btn = gr.Button("📊 Analyze Channel", variant="primary")
|
| 864 |
+
ca_output = gr.Textbox(label="Channel Analysis", lines=25, elem_classes="llm-result")
|
| 865 |
+
|
| 866 |
+
# Competitor Analysis
|
| 867 |
+
with gr.Tab("⚔️ Competitor Analysis"):
|
| 868 |
+
gr.Markdown("**Compare against competitors**")
|
| 869 |
+
with gr.Row():
|
| 870 |
+
comp_my = gr.Textbox(label="Your Channel/Topic", placeholder="e.g., My cooking channel")
|
| 871 |
+
comp_rival = gr.Textbox(label="Competitor", placeholder="e.g., Gordon Ramsay")
|
| 872 |
+
comp_btn = gr.Button("⚔️ Analyze Competition", variant="primary")
|
| 873 |
+
comp_output = gr.Textbox(label="Competitive Analysis", lines=25, elem_classes="llm-result")
|
| 874 |
+
|
| 875 |
+
# Tab 6: History
|
| 876 |
+
with gr.Tab("🕐 History"):
|
| 877 |
+
history_btn = gr.Button("🔄 Refresh", variant="primary")
|
| 878 |
history_out = gr.HTML()
|
| 879 |
|
| 880 |
+
# Events
|
| 881 |
trending.change(use_trending_keyword, trending, keyword)
|
| 882 |
country.change(update_trending, country, trending)
|
| 883 |
btn.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
|
| 884 |
keyword.submit(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
|
| 885 |
refresh.click(search_videos, [keyword, country, language, sort_by, date_filter, max_results], [output, db_stats])
|
| 886 |
|
| 887 |
+
pick_btn.click(show_ai_picks, outputs=pick_out)
|
| 888 |
alerts_btn.click(show_trending_alerts, outputs=alerts_out)
|
| 889 |
growing_btn.click(show_top_growing, outputs=growing_out)
|
| 890 |
history_btn.click(show_search_history, outputs=history_out)
|
| 891 |
+
|
| 892 |
+
# AI Tools events
|
| 893 |
+
kw_btn.click(analyze_keyword_suggest, kw_input, kw_output)
|
| 894 |
+
tp_btn.click(analyze_trend_prediction, tp_input, tp_output)
|
| 895 |
+
ci_btn.click(analyze_content_ideas, ci_input, ci_output)
|
| 896 |
+
ca_btn.click(analyze_channel, ca_input, ca_output)
|
| 897 |
+
comp_btn.click(analyze_competitor, [comp_my, comp_rival], comp_output)
|
| 898 |
|
| 899 |
demo.launch()
|
| 900 |
|
| 901 |
+
|