|
|
|
|
|
|
|
|
from flask import Flask, render_template_string, jsonify, request |
|
|
import requests |
|
|
import json |
|
|
from datetime import datetime, timedelta |
|
|
from typing import List, Dict, Optional |
|
|
import os |
|
|
import sys |
|
|
import sqlite3 |
|
|
import time |
|
|
from huggingface_hub import HfApi |
|
|
from bs4 import BeautifulSoup |
|
|
import re |
|
|
|
|
|
|
|
|
app = Flask(__name__) |
|
|
app.config['JSON_AS_ASCII'] = False |
|
|
|
|
|
|
|
|
DB_PATH = 'ai_news_analysis.db' |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HTML_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ko"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> |
|
|
<title>데일리 AI 탑 100</title> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Segoe UI', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
padding: 20px; |
|
|
color: #333; |
|
|
min-height: 100vh; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 1400px; |
|
|
margin: 0 auto; |
|
|
background: white; |
|
|
border-radius: 20px; |
|
|
padding: 40px; |
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
|
|
word-wrap: break-word; |
|
|
overflow-wrap: break-word; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
text-align: center; |
|
|
color: #667eea; |
|
|
margin-bottom: 15px; |
|
|
font-size: 2.8em; |
|
|
font-weight: 800; |
|
|
word-break: keep-all; |
|
|
line-height: 1.3; |
|
|
} |
|
|
|
|
|
.subtitle { |
|
|
text-align: center; |
|
|
color: #666; |
|
|
margin-bottom: 25px; |
|
|
font-size: 1.1em; |
|
|
line-height: 1.8; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
.badges { |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
gap: 15px; |
|
|
margin-bottom: 40px; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.badges a { |
|
|
transition: transform 0.3s ease; |
|
|
display: inline-block; |
|
|
min-height: 44px; |
|
|
min-width: 44px; |
|
|
} |
|
|
|
|
|
.badges a:hover { |
|
|
transform: translateY(-3px); |
|
|
} |
|
|
|
|
|
.badges img { |
|
|
height: 32px; |
|
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15); |
|
|
border-radius: 5px; |
|
|
} |
|
|
|
|
|
/* 탭 스타일 */ |
|
|
.tabs { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
margin-bottom: 30px; |
|
|
border-bottom: 3px solid #e0e0e0; |
|
|
padding-bottom: 0; |
|
|
overflow-x: auto; |
|
|
-webkit-overflow-scrolling: touch; |
|
|
} |
|
|
|
|
|
.tab { |
|
|
padding: 15px 25px; |
|
|
background: #f5f5f5; |
|
|
border: none; |
|
|
border-radius: 10px 10px 0 0; |
|
|
cursor: pointer; |
|
|
font-size: 1em; |
|
|
font-weight: 600; |
|
|
color: #666; |
|
|
transition: all 0.3s; |
|
|
white-space: nowrap; |
|
|
min-height: 48px; |
|
|
min-width: 100px; |
|
|
} |
|
|
|
|
|
.tab.active { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
transform: translateY(-3px); |
|
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); |
|
|
} |
|
|
|
|
|
.tab:hover { |
|
|
background: #e0e0e0; |
|
|
} |
|
|
|
|
|
.tab.active:hover { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
} |
|
|
|
|
|
.tab-content { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.tab-content.active { |
|
|
display: block; |
|
|
animation: fadeIn 0.5s ease-out; |
|
|
} |
|
|
|
|
|
/* 통계 카드 */ |
|
|
.stats { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); |
|
|
gap: 20px; |
|
|
margin-bottom: 50px; |
|
|
} |
|
|
|
|
|
.stat-card { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
padding: 25px; |
|
|
border-radius: 15px; |
|
|
text-align: center; |
|
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); |
|
|
transform: translateY(0); |
|
|
transition: transform 0.3s, box-shadow 0.3s; |
|
|
} |
|
|
|
|
|
.stat-card:hover { |
|
|
transform: translateY(-5px); |
|
|
box-shadow: 0 12px 30px rgba(102, 126, 234, 0.6); |
|
|
} |
|
|
|
|
|
.stat-number { |
|
|
font-size: 2.8em; |
|
|
font-weight: bold; |
|
|
margin-bottom: 10px; |
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.2); |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
font-size: 1.1em; |
|
|
opacity: 0.95; |
|
|
font-weight: 500; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
/* 뉴스 카드 */ |
|
|
.news-card { |
|
|
background: white; |
|
|
border-radius: 15px; |
|
|
padding: 25px; |
|
|
margin-bottom: 25px; |
|
|
box-shadow: 0 5px 20px rgba(0,0,0,0.1); |
|
|
border-left: 6px solid #667eea; |
|
|
transition: all 0.3s; |
|
|
word-wrap: break-word; |
|
|
overflow-wrap: break-word; |
|
|
} |
|
|
|
|
|
.news-card:hover { |
|
|
transform: translateX(5px); |
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.15); |
|
|
} |
|
|
|
|
|
.news-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: flex-start; |
|
|
margin-bottom: 20px; |
|
|
flex-wrap: wrap; |
|
|
gap: 15px; |
|
|
} |
|
|
|
|
|
.news-title { |
|
|
font-size: 1.3em; |
|
|
font-weight: 700; |
|
|
color: #2c3e50; |
|
|
flex: 1; |
|
|
min-width: 200px; |
|
|
word-break: keep-all; |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
.news-meta { |
|
|
display: flex; |
|
|
gap: 15px; |
|
|
color: #7f8c8d; |
|
|
font-size: 0.9em; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.analysis-section { |
|
|
background: #f8f9fa; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
margin-top: 15px; |
|
|
} |
|
|
|
|
|
.analysis-item { |
|
|
margin-bottom: 20px; |
|
|
padding-bottom: 20px; |
|
|
border-bottom: 1px solid #e0e0e0; |
|
|
} |
|
|
|
|
|
.analysis-item:last-child { |
|
|
border-bottom: none; |
|
|
margin-bottom: 0; |
|
|
padding-bottom: 0; |
|
|
} |
|
|
|
|
|
.analysis-label { |
|
|
display: inline-block; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
padding: 8px 15px; |
|
|
border-radius: 20px; |
|
|
font-size: 0.9em; |
|
|
font-weight: 600; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.analysis-content { |
|
|
color: #34495e; |
|
|
line-height: 1.8; |
|
|
font-size: 1em; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
.impact-level { |
|
|
display: inline-block; |
|
|
padding: 5px 12px; |
|
|
border-radius: 15px; |
|
|
font-size: 0.85em; |
|
|
font-weight: 600; |
|
|
margin-left: 10px; |
|
|
} |
|
|
|
|
|
.impact-high { |
|
|
background: #ff6b6b; |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.impact-medium { |
|
|
background: #ffa502; |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.impact-low { |
|
|
background: #26de81; |
|
|
color: white; |
|
|
} |
|
|
|
|
|
/* 모델 카드 */ |
|
|
.model-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
|
|
gap: 25px; |
|
|
margin-top: 30px; |
|
|
} |
|
|
|
|
|
.model-card { |
|
|
background: white; |
|
|
padding: 25px; |
|
|
border-radius: 12px; |
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1); |
|
|
transition: all 0.3s; |
|
|
border-top: 4px solid #667eea; |
|
|
position: relative; |
|
|
word-wrap: break-word; |
|
|
overflow-wrap: break-word; |
|
|
} |
|
|
|
|
|
.model-card:hover { |
|
|
transform: translateY(-5px); |
|
|
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3); |
|
|
} |
|
|
|
|
|
.model-rank { |
|
|
position: absolute; |
|
|
top: -15px; |
|
|
right: 20px; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
border-radius: 50%; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
font-weight: 700; |
|
|
font-size: 1.2em; |
|
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); |
|
|
} |
|
|
|
|
|
.model-name { |
|
|
font-weight: 700; |
|
|
color: #667eea; |
|
|
margin-bottom: 15px; |
|
|
font-size: 1.1em; |
|
|
word-break: break-word; |
|
|
padding-right: 60px; |
|
|
line-height: 1.4; |
|
|
} |
|
|
|
|
|
.model-stats { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(2, 1fr); |
|
|
gap: 10px; |
|
|
margin: 15px 0; |
|
|
padding: 15px; |
|
|
background: #f8f9fa; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.model-stat-item { |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.model-task { |
|
|
background: #e8f0fe; |
|
|
color: #667eea; |
|
|
padding: 6px 12px; |
|
|
border-radius: 20px; |
|
|
font-size: 0.85em; |
|
|
display: inline-block; |
|
|
margin-bottom: 15px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.model-analysis { |
|
|
background: #f0f4ff; |
|
|
padding: 15px; |
|
|
border-radius: 8px; |
|
|
margin-top: 15px; |
|
|
color: #34495e; |
|
|
line-height: 1.7; |
|
|
font-size: 0.95em; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
/* 스페이스 카드 */ |
|
|
.space-card { |
|
|
background: white; |
|
|
padding: 25px; |
|
|
border-radius: 12px; |
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1); |
|
|
margin-bottom: 20px; |
|
|
border-left: 5px solid #ff6b6b; |
|
|
transition: all 0.3s; |
|
|
word-wrap: break-word; |
|
|
overflow-wrap: break-word; |
|
|
} |
|
|
|
|
|
.space-card:hover { |
|
|
transform: translateX(5px); |
|
|
box-shadow: 0 10px 25px rgba(255, 107, 107, 0.3); |
|
|
} |
|
|
|
|
|
.space-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: flex-start; |
|
|
margin-bottom: 15px; |
|
|
flex-wrap: wrap; |
|
|
gap: 10px; |
|
|
} |
|
|
|
|
|
.space-name { |
|
|
font-weight: 700; |
|
|
color: #ff6b6b; |
|
|
font-size: 1.2em; |
|
|
word-break: break-word; |
|
|
line-height: 1.4; |
|
|
} |
|
|
|
|
|
.space-badge { |
|
|
background: #ff6b6b; |
|
|
color: white; |
|
|
padding: 5px 12px; |
|
|
border-radius: 15px; |
|
|
font-size: 0.8em; |
|
|
font-weight: 600; |
|
|
white-space: nowrap; |
|
|
} |
|
|
|
|
|
.space-description { |
|
|
color: #555; |
|
|
margin-bottom: 15px; |
|
|
line-height: 1.6; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
.space-analysis { |
|
|
background: #fff5f5; |
|
|
padding: 15px; |
|
|
border-radius: 8px; |
|
|
margin-top: 15px; |
|
|
word-break: keep-all; |
|
|
line-height: 1.7; |
|
|
} |
|
|
|
|
|
.space-tech { |
|
|
display: flex; |
|
|
flex-wrap: wrap; |
|
|
gap: 8px; |
|
|
margin-top: 15px; |
|
|
} |
|
|
|
|
|
.tech-tag { |
|
|
background: #ffe5e5; |
|
|
color: #ff6b6b; |
|
|
padding: 5px 10px; |
|
|
border-radius: 12px; |
|
|
font-size: 0.8em; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
/* 버튼 */ |
|
|
.button-group { |
|
|
text-align: center; |
|
|
margin: 40px 0; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
gap: 15px; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.refresh-btn { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
border: none; |
|
|
padding: 18px 40px; |
|
|
font-size: 1.1em; |
|
|
font-weight: 700; |
|
|
border-radius: 50px; |
|
|
cursor: pointer; |
|
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); |
|
|
transition: all 0.3s; |
|
|
min-height: 48px; |
|
|
min-width: 120px; |
|
|
} |
|
|
|
|
|
.refresh-btn:hover { |
|
|
transform: scale(1.05); |
|
|
box-shadow: 0 12px 30px rgba(102, 126, 234, 0.6); |
|
|
} |
|
|
|
|
|
.refresh-btn:active { |
|
|
transform: scale(0.98); |
|
|
} |
|
|
|
|
|
.news-link { |
|
|
display: inline-block; |
|
|
background: #667eea; |
|
|
color: white; |
|
|
padding: 12px 24px; |
|
|
border-radius: 8px; |
|
|
text-decoration: none; |
|
|
font-size: 0.95em; |
|
|
font-weight: 600; |
|
|
transition: all 0.3s; |
|
|
margin-top: 15px; |
|
|
min-height: 44px; |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
.news-link:hover { |
|
|
background: #764ba2; |
|
|
transform: scale(1.05); |
|
|
} |
|
|
|
|
|
.loading { |
|
|
text-align: center; |
|
|
padding: 60px 20px; |
|
|
font-size: 1.5em; |
|
|
color: #667eea; |
|
|
font-weight: 600; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
.timestamp { |
|
|
text-align: center; |
|
|
color: #999; |
|
|
margin-top: 40px; |
|
|
font-size: 1em; |
|
|
padding: 20px; |
|
|
background: #f8f9fa; |
|
|
border-radius: 10px; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
.footer { |
|
|
text-align: center; |
|
|
margin-top: 50px; |
|
|
padding-top: 30px; |
|
|
border-top: 2px solid #e0e0e0; |
|
|
color: #666; |
|
|
word-break: keep-all; |
|
|
} |
|
|
|
|
|
@keyframes fadeIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(20px); |
|
|
} |
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
/* 태블릿 최적화 (768px ~ 1024px) */ |
|
|
@media (max-width: 1024px) { |
|
|
.container { |
|
|
padding: 30px; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
font-size: 2.2em; |
|
|
} |
|
|
|
|
|
.model-grid { |
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|
|
} |
|
|
} |
|
|
|
|
|
/* 모바일 최적화 (max-width: 768px) */ |
|
|
@media (max-width: 768px) { |
|
|
body { |
|
|
padding: 10px; |
|
|
} |
|
|
|
|
|
.container { |
|
|
padding: 20px; |
|
|
border-radius: 15px; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
font-size: 1.8em; |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
.subtitle { |
|
|
font-size: 0.95em; |
|
|
padding: 0 10px; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.badges { |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
gap: 10px; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
.badges a { |
|
|
width: 100%; |
|
|
max-width: 300px; |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
.badges img { |
|
|
height: 28px; |
|
|
max-width: 100%; |
|
|
} |
|
|
|
|
|
.tabs { |
|
|
gap: 8px; |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
.tab { |
|
|
padding: 12px 18px; |
|
|
font-size: 0.9em; |
|
|
min-width: 80px; |
|
|
} |
|
|
|
|
|
.stats { |
|
|
grid-template-columns: repeat(2, 1fr); |
|
|
gap: 15px; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
.stat-card { |
|
|
padding: 20px; |
|
|
} |
|
|
|
|
|
.stat-number { |
|
|
font-size: 2.2em; |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
font-size: 0.95em; |
|
|
} |
|
|
|
|
|
.news-card { |
|
|
padding: 20px; |
|
|
margin-bottom: 20px; |
|
|
border-left-width: 4px; |
|
|
} |
|
|
|
|
|
.news-card:hover { |
|
|
transform: translateX(0); |
|
|
} |
|
|
|
|
|
.news-title { |
|
|
font-size: 1.15em; |
|
|
min-width: 100%; |
|
|
} |
|
|
|
|
|
.news-meta { |
|
|
font-size: 0.85em; |
|
|
gap: 10px; |
|
|
} |
|
|
|
|
|
.analysis-section { |
|
|
padding: 15px; |
|
|
} |
|
|
|
|
|
.analysis-label { |
|
|
font-size: 0.85em; |
|
|
padding: 6px 12px; |
|
|
} |
|
|
|
|
|
.analysis-content { |
|
|
font-size: 0.95em; |
|
|
} |
|
|
|
|
|
.model-grid { |
|
|
grid-template-columns: 1fr; |
|
|
gap: 20px; |
|
|
} |
|
|
|
|
|
.model-card { |
|
|
padding: 20px; |
|
|
} |
|
|
|
|
|
.model-rank { |
|
|
width: 45px; |
|
|
height: 45px; |
|
|
font-size: 1.1em; |
|
|
top: -12px; |
|
|
right: 15px; |
|
|
} |
|
|
|
|
|
.model-name { |
|
|
font-size: 1em; |
|
|
padding-right: 55px; |
|
|
} |
|
|
|
|
|
.model-stats { |
|
|
gap: 8px; |
|
|
padding: 12px; |
|
|
} |
|
|
|
|
|
.model-stat-item { |
|
|
font-size: 0.85em; |
|
|
} |
|
|
|
|
|
.space-card { |
|
|
padding: 20px; |
|
|
border-left-width: 4px; |
|
|
} |
|
|
|
|
|
.space-card:hover { |
|
|
transform: translateX(0); |
|
|
} |
|
|
|
|
|
.space-name { |
|
|
font-size: 1.1em; |
|
|
} |
|
|
|
|
|
.button-group { |
|
|
flex-direction: column; |
|
|
gap: 10px; |
|
|
} |
|
|
|
|
|
.refresh-btn { |
|
|
width: 100%; |
|
|
padding: 15px 30px; |
|
|
font-size: 1em; |
|
|
} |
|
|
|
|
|
.news-link { |
|
|
padding: 10px 20px; |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.loading { |
|
|
padding: 40px 15px; |
|
|
font-size: 1.2em; |
|
|
} |
|
|
|
|
|
.timestamp { |
|
|
font-size: 0.9em; |
|
|
padding: 15px; |
|
|
} |
|
|
|
|
|
.footer { |
|
|
font-size: 0.9em; |
|
|
margin-top: 30px; |
|
|
} |
|
|
|
|
|
.footer p { |
|
|
margin-top: 8px; |
|
|
} |
|
|
} |
|
|
|
|
|
/* 초소형 모바일 (max-width: 480px) */ |
|
|
@media (max-width: 480px) { |
|
|
body { |
|
|
padding: 5px; |
|
|
} |
|
|
|
|
|
.container { |
|
|
padding: 15px; |
|
|
border-radius: 10px; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
font-size: 1.5em; |
|
|
} |
|
|
|
|
|
.subtitle { |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.tabs { |
|
|
gap: 5px; |
|
|
} |
|
|
|
|
|
.tab { |
|
|
padding: 10px 12px; |
|
|
font-size: 0.85em; |
|
|
min-width: 70px; |
|
|
} |
|
|
|
|
|
.stats { |
|
|
grid-template-columns: 1fr; |
|
|
gap: 12px; |
|
|
} |
|
|
|
|
|
.stat-number { |
|
|
font-size: 2em; |
|
|
} |
|
|
|
|
|
.stat-label { |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.news-card, |
|
|
.model-card, |
|
|
.space-card { |
|
|
padding: 15px; |
|
|
} |
|
|
|
|
|
.news-title { |
|
|
font-size: 1.05em; |
|
|
} |
|
|
|
|
|
.analysis-section { |
|
|
padding: 12px; |
|
|
} |
|
|
|
|
|
.analysis-content { |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.model-rank { |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
font-size: 1em; |
|
|
} |
|
|
|
|
|
.refresh-btn { |
|
|
padding: 12px 25px; |
|
|
font-size: 0.95em; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>🤖 투데이 AI : 데일리 TOP 100 소식</h1> |
|
|
<p class="subtitle"> |
|
|
매일 아침 전 세계 AI 생태계의 핵심 100가지를 한눈에 확인하세요.<br> |
|
|
최신 뉴스·모델·서비스를 AI가 직접 분석해서 쉽게 설명해드립니다. |
|
|
</p> |
|
|
|
|
|
<div class="badges"> |
|
|
<a href="https://open.kakao.com/o/peIe8KWh" target="_blank"> |
|
|
<img src="https://img.shields.io/static/v1?label=%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1&message=%EC%98%A4%ED%94%88%EC%B1%84%ED%8C%85&color=%230000ff&labelColor=%23800080&logo=huggingface&logoColor=white&style=for-the-badge" alt="카카오톡 오픈채팅"> |
|
|
</a> |
|
|
<a href="https://ginigen.ai" target="_blank"> |
|
|
<img src="https://img.shields.io/static/v1?label=%EB%82%98%EB%85%B8%20%EB%B0%94%EB%82%98%EB%82%98&message=%EC%95%A0%EB%93%9C%EC%98%A8%20%EC%84%9C%EB%B9%84%EC%8A%A4&color=%230000ff&labelColor=%23800080&logo=huggingface&logoColor=white&style=for-the-badge" alt="나노 바나나 애드온"> |
|
|
</a> |
|
|
<a href="https://discord.gg/openfreeai" target="_blank"> |
|
|
<img src="https://img.shields.io/static/v1?label=Discord&message=OpenFree%20AI%20%EC%BB%A4%EB%AE%A4%EB%8B%88%ED%8B%B0&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge" alt="Discord 커뮤니티"> |
|
|
</a> |
|
|
</div> |
|
|
|
|
|
<!-- 통계 카드 --> |
|
|
<div class="stats"> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-number">{{ stats.total_news }}</div> |
|
|
<div class="stat-label">📰 분석된 뉴스</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-number">{{ stats.hf_models }}</div> |
|
|
<div class="stat-label">🤗 트렌딩 모델</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-number">{{ stats.hf_spaces }}</div> |
|
|
<div class="stat-label">🚀 인기 스페이스</div> |
|
|
</div> |
|
|
<div class="stat-card"> |
|
|
<div class="stat-number">{{ stats.llm_analyses }}</div> |
|
|
<div class="stat-label">🧠 LLM 분석</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- 탭 메뉴 --> |
|
|
<div class="tabs"> |
|
|
<button class="tab active" onclick="switchTab(event, 'news')">📰 AI 뉴스 분석</button> |
|
|
<button class="tab" onclick="switchTab(event, 'models')">🤗 트렌딩 모델</button> |
|
|
<button class="tab" onclick="switchTab(event, 'spaces')">🚀 인기 스페이스</button> |
|
|
</div> |
|
|
|
|
|
<!-- 뉴스 탭 --> |
|
|
<div id="news-content" class="tab-content active"> |
|
|
{% for article in analyzed_news %} |
|
|
<div class="news-card"> |
|
|
<div class="news-header"> |
|
|
<div class="news-title">{{ loop.index }}. {{ article.title }}</div> |
|
|
<div class="news-meta"> |
|
|
<span>📅 {{ article.date }}</span> |
|
|
<span>📰 {{ article.source }}</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="analysis-section"> |
|
|
<div class="analysis-item"> |
|
|
<span class="analysis-label">🎯 쉬운 요약</span> |
|
|
<div class="analysis-content">{{ article.analysis.summary }}</div> |
|
|
</div> |
|
|
|
|
|
<div class="analysis-item"> |
|
|
<span class="analysis-label">💡 왜 중요할까?</span> |
|
|
<div class="analysis-content">{{ article.analysis.significance }}</div> |
|
|
</div> |
|
|
|
|
|
<div class="analysis-item"> |
|
|
<span class="analysis-label">📊 영향도</span> |
|
|
<span class="impact-level impact-{{ article.analysis.impact_level }}"> |
|
|
{{ article.analysis.impact_text }} |
|
|
</span> |
|
|
<div class="analysis-content" style="margin-top: 10px;"> |
|
|
{{ article.analysis.impact_description }} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="analysis-item"> |
|
|
<span class="analysis-label">✅ 우리가 할 수 있는 것</span> |
|
|
<div class="analysis-content">{{ article.analysis.action }}</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<a href="{{ article.url }}" target="_blank" class="news-link"> |
|
|
🔗 전체 기사 읽어보기 |
|
|
</a> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
|
|
|
<!-- 모델 탭 --> |
|
|
<div id="models-content" class="tab-content"> |
|
|
<div class="model-grid"> |
|
|
{% for model in analyzed_models %} |
|
|
<div class="model-card"> |
|
|
<div class="model-rank">{{ model.rank }}</div> |
|
|
<div class="model-name">{{ model.name }}</div> |
|
|
<div class="model-task">🏷️ {{ model.task }}</div> |
|
|
|
|
|
<div class="model-stats"> |
|
|
<div class="model-stat-item"> |
|
|
<strong>📥 다운로드</strong><br> |
|
|
{{ "{:,}".format(model.downloads) }} |
|
|
</div> |
|
|
<div class="model-stat-item"> |
|
|
<strong>❤️ 좋아요</strong><br> |
|
|
{{ "{:,}".format(model.likes) }} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="model-analysis"> |
|
|
<strong>🧠 AI 분석:</strong><br> |
|
|
{{ model.analysis }} |
|
|
</div> |
|
|
|
|
|
<a href="{{ model.url }}" target="_blank" class="news-link"> |
|
|
🔗 모델 페이지 방문 |
|
|
</a> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
|
|
|
{% if analyzed_models|length == 0 %} |
|
|
<div class="loading"> |
|
|
⚠️ 모델 데이터를 불러오는 중...<br> |
|
|
<button onclick="location.href='/?refresh=true'" style="margin-top: 20px; padding: 15px 30px; font-size: 1.1em; cursor: pointer; background: #667eea; color: white; border: none; border-radius: 25px; min-height: 48px;"> |
|
|
🔥 데이터 수집하기 |
|
|
</button> |
|
|
</div> |
|
|
{% endif %} |
|
|
</div> |
|
|
|
|
|
<!-- 스페이스 탭 --> |
|
|
<div id="spaces-content" class="tab-content"> |
|
|
{% for space in analyzed_spaces %} |
|
|
<div class="space-card"> |
|
|
<div class="space-header"> |
|
|
<div class="space-name">{{ space.rank }}. {{ space.name }}</div> |
|
|
<span class="space-badge">트렌딩 {{ space.rank }}위</span> |
|
|
</div> |
|
|
|
|
|
<div class="space-description"> |
|
|
<strong>📝 설명:</strong> {{ space.description }} |
|
|
</div> |
|
|
|
|
|
<div class="space-analysis"> |
|
|
<strong>🎓 쉬운 설명:</strong><br> |
|
|
{{ space.simple_explanation }} |
|
|
</div> |
|
|
|
|
|
{% if space.tech_stack %} |
|
|
<div class="space-tech"> |
|
|
<strong style="width: 100%; margin-bottom: 5px;">🛠️ 사용 기술:</strong> |
|
|
{% for tech in space.tech_stack %} |
|
|
<span class="tech-tag">{{ tech }}</span> |
|
|
{% endfor %} |
|
|
</div> |
|
|
{% endif %} |
|
|
|
|
|
<a href="{{ space.url }}" target="_blank" class="news-link"> |
|
|
🔗 스페이스 체험하기 |
|
|
</a> |
|
|
</div> |
|
|
{% endfor %} |
|
|
|
|
|
{% if analyzed_spaces|length == 0 %} |
|
|
<div class="loading"> |
|
|
⚠️ 스페이스 데이터를 불러오는 중...<br> |
|
|
<button onclick="location.href='/?refresh=true'" style="margin-top: 20px; padding: 15px 30px; font-size: 1.1em; cursor: pointer; background: #ff6b6b; color: white; border: none; border-radius: 25px; min-height: 48px;"> |
|
|
🔥 데이터 수집하기 |
|
|
</button> |
|
|
</div> |
|
|
{% endif %} |
|
|
</div> |
|
|
|
|
|
<!-- 버튼 그룹 --> |
|
|
<div class="button-group"> |
|
|
<button class="refresh-btn" onclick="location.reload()"> |
|
|
🔄 페이지 새로고침 |
|
|
</button> |
|
|
<button class="refresh-btn" onclick="location.href='/?refresh=true'" style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);"> |
|
|
🔥 데이터 강제 갱신 |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<!-- 타임스탬프 --> |
|
|
<div class="timestamp"> |
|
|
⏰ 마지막 업데이트: {{ timestamp }} |
|
|
</div> |
|
|
|
|
|
<!-- 푸터 --> |
|
|
<div class="footer"> |
|
|
<p>🤖 투데이 AI: 데일리 TOP 100 v3.4</p> |
|
|
<p style="margin-top: 10px; font-size: 0.9em;"> |
|
|
💾 SQLite DB 영구 저장 | 🌐 AI Times + Hacker News 실시간 수집 | 🤗 Hugging Face Trending API | 🧠 Powered by Fireworks AI (Qwen3-235B) |
|
|
</p> |
|
|
<p style="margin-top: 10px; font-size: 0.85em; color: #999;"> |
|
|
데이터 출처: AI Times, Hacker News, Hugging Face | 실시간 분석: Fireworks AI |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
function switchTab(event, tabName) { |
|
|
// 모든 탭 비활성화 |
|
|
document.querySelectorAll('.tab').forEach(tab => { |
|
|
tab.classList.remove('active'); |
|
|
}); |
|
|
document.querySelectorAll('.tab-content').forEach(content => { |
|
|
content.classList.remove('active'); |
|
|
}); |
|
|
|
|
|
// 선택된 탭 활성화 |
|
|
event.currentTarget.classList.add('active'); |
|
|
document.getElementById(tabName + '-content').classList.add('active'); |
|
|
|
|
|
// 스크롤을 맨 위로 |
|
|
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|
|
} |
|
|
|
|
|
// 터치 스와이프 지원 |
|
|
let touchStartX = 0; |
|
|
let touchEndX = 0; |
|
|
|
|
|
document.addEventListener('touchstart', e => { |
|
|
touchStartX = e.changedTouches[0].screenX; |
|
|
}); |
|
|
|
|
|
document.addEventListener('touchend', e => { |
|
|
touchEndX = e.changedTouches[0].screenX; |
|
|
handleSwipe(); |
|
|
}); |
|
|
|
|
|
function handleSwipe() { |
|
|
const swipeThreshold = 50; |
|
|
if (touchEndX < touchStartX - swipeThreshold) { |
|
|
// 왼쪽으로 스와이프 - 다음 탭 |
|
|
const activeTab = document.querySelector('.tab.active'); |
|
|
const nextTab = activeTab.nextElementSibling; |
|
|
if (nextTab && nextTab.classList.contains('tab')) { |
|
|
const tabName = nextTab.textContent.includes('뉴스') ? 'news' : |
|
|
nextTab.textContent.includes('모델') ? 'models' : 'spaces'; |
|
|
switchTab({ currentTarget: nextTab }, tabName); |
|
|
} |
|
|
} |
|
|
|
|
|
if (touchEndX > touchStartX + swipeThreshold) { |
|
|
// 오른쪽으로 스와이프 - 이전 탭 |
|
|
const activeTab = document.querySelector('.tab.active'); |
|
|
const prevTab = activeTab.previousElementSibling; |
|
|
if (prevTab && prevTab.classList.contains('tab')) { |
|
|
const tabName = prevTab.textContent.includes('뉴스') ? 'news' : |
|
|
prevTab.textContent.includes('모델') ? 'models' : 'spaces'; |
|
|
switchTab({ currentTarget: prevTab }, tabName); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
console.log('✅ 투데이 AI: 데일리 TOP 100 소식 로드 완료 (모바일 최적화 v3.4)'); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def init_database(): |
|
|
"""SQLite 데이터베이스 초기화""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
|
|
|
cursor.execute(''' |
|
|
CREATE TABLE IF NOT EXISTS news ( |
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
|
title TEXT NOT NULL, |
|
|
url TEXT NOT NULL UNIQUE, |
|
|
date TEXT, |
|
|
source TEXT, |
|
|
category TEXT, |
|
|
summary TEXT, |
|
|
significance TEXT, |
|
|
impact_level TEXT, |
|
|
impact_text TEXT, |
|
|
impact_description TEXT, |
|
|
action TEXT, |
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
|
|
) |
|
|
''') |
|
|
|
|
|
|
|
|
cursor.execute(''' |
|
|
CREATE TABLE IF NOT EXISTS models ( |
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
|
name TEXT NOT NULL UNIQUE, |
|
|
downloads INTEGER, |
|
|
likes INTEGER, |
|
|
task TEXT, |
|
|
url TEXT, |
|
|
analysis TEXT, |
|
|
rank INTEGER, |
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
|
|
) |
|
|
''') |
|
|
|
|
|
|
|
|
cursor.execute(''' |
|
|
CREATE TABLE IF NOT EXISTS spaces ( |
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
|
space_id TEXT NOT NULL UNIQUE, |
|
|
name TEXT NOT NULL, |
|
|
author TEXT, |
|
|
title TEXT, |
|
|
likes INTEGER, |
|
|
url TEXT, |
|
|
sdk TEXT, |
|
|
simple_explanation TEXT, |
|
|
tech_stack TEXT, |
|
|
rank INTEGER, |
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
|
|
) |
|
|
''') |
|
|
|
|
|
conn.commit() |
|
|
conn.close() |
|
|
print("✅ 데이터베이스 초기화 완료") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_news_to_db(news_list: List[Dict]): |
|
|
"""뉴스 데이터를 DB에 저장""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
saved_count = 0 |
|
|
for news in news_list: |
|
|
try: |
|
|
cursor.execute(''' |
|
|
INSERT OR REPLACE INTO news |
|
|
(title, url, date, source, category, summary, significance, |
|
|
impact_level, impact_text, impact_description, action) |
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
''', ( |
|
|
news['title'], |
|
|
news['url'], |
|
|
news.get('date', ''), |
|
|
news.get('source', ''), |
|
|
news.get('category', ''), |
|
|
news['analysis']['summary'], |
|
|
news['analysis']['significance'], |
|
|
news['analysis']['impact_level'], |
|
|
news['analysis']['impact_text'], |
|
|
news['analysis']['impact_description'], |
|
|
news['analysis']['action'] |
|
|
)) |
|
|
saved_count += 1 |
|
|
except sqlite3.IntegrityError: |
|
|
pass |
|
|
|
|
|
conn.commit() |
|
|
conn.close() |
|
|
print(f"✅ {saved_count}개 뉴스 DB 저장 완료") |
|
|
|
|
|
|
|
|
def save_models_to_db(models_list: List[Dict]): |
|
|
"""모델 데이터를 DB에 저장""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
saved_count = 0 |
|
|
for model in models_list: |
|
|
try: |
|
|
cursor.execute(''' |
|
|
INSERT OR REPLACE INTO models |
|
|
(name, downloads, likes, task, url, analysis, rank, updated_at) |
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) |
|
|
''', ( |
|
|
model['name'], |
|
|
model['downloads'], |
|
|
model['likes'], |
|
|
model['task'], |
|
|
model['url'], |
|
|
model['analysis'], |
|
|
model['rank'] |
|
|
)) |
|
|
saved_count += 1 |
|
|
except Exception as e: |
|
|
print(f"⚠️ 모델 저장 오류: {e}") |
|
|
|
|
|
conn.commit() |
|
|
conn.close() |
|
|
print(f"✅ {saved_count}개 모델 DB 저장 완료") |
|
|
|
|
|
|
|
|
def save_spaces_to_db(spaces_list: List[Dict]): |
|
|
"""스페이스 데이터를 DB에 저장""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
saved_count = 0 |
|
|
for space in spaces_list: |
|
|
try: |
|
|
cursor.execute(''' |
|
|
INSERT OR REPLACE INTO spaces |
|
|
(space_id, name, author, title, likes, url, sdk, |
|
|
simple_explanation, tech_stack, rank, updated_at) |
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) |
|
|
''', ( |
|
|
space['space_id'], |
|
|
space['name'], |
|
|
space.get('author', ''), |
|
|
space.get('title', ''), |
|
|
space.get('likes', 0), |
|
|
space['url'], |
|
|
space.get('sdk', ''), |
|
|
space['simple_explanation'], |
|
|
json.dumps(space.get('tech_stack', [])), |
|
|
space['rank'] |
|
|
)) |
|
|
saved_count += 1 |
|
|
except Exception as e: |
|
|
print(f"⚠️ 스페이스 저장 오류: {e}") |
|
|
|
|
|
conn.commit() |
|
|
conn.close() |
|
|
print(f"✅ {saved_count}개 스페이스 DB 저장 완료") |
|
|
|
|
|
|
|
|
def load_news_from_db() -> List[Dict]: |
|
|
"""DB에서 뉴스 로드""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
cursor.execute(''' |
|
|
SELECT title, url, date, source, category, summary, significance, |
|
|
impact_level, impact_text, impact_description, action |
|
|
FROM news ORDER BY created_at DESC LIMIT 50 |
|
|
''') |
|
|
|
|
|
news_list = [] |
|
|
for row in cursor.fetchall(): |
|
|
news_list.append({ |
|
|
'title': row[0], |
|
|
'url': row[1], |
|
|
'date': row[2], |
|
|
'source': row[3], |
|
|
'category': row[4], |
|
|
'analysis': { |
|
|
'summary': row[5], |
|
|
'significance': row[6], |
|
|
'impact_level': row[7], |
|
|
'impact_text': row[8], |
|
|
'impact_description': row[9], |
|
|
'action': row[10] |
|
|
} |
|
|
}) |
|
|
|
|
|
conn.close() |
|
|
return news_list |
|
|
|
|
|
|
|
|
def load_models_from_db() -> List[Dict]: |
|
|
"""DB에서 모델 로드""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
cursor.execute(''' |
|
|
SELECT name, downloads, likes, task, url, analysis, rank |
|
|
FROM models ORDER BY rank ASC LIMIT 30 |
|
|
''') |
|
|
|
|
|
models_list = [] |
|
|
for row in cursor.fetchall(): |
|
|
models_list.append({ |
|
|
'name': row[0], |
|
|
'downloads': row[1], |
|
|
'likes': row[2], |
|
|
'task': row[3], |
|
|
'url': row[4], |
|
|
'analysis': row[5], |
|
|
'rank': row[6] |
|
|
}) |
|
|
|
|
|
conn.close() |
|
|
return models_list |
|
|
|
|
|
|
|
|
def load_spaces_from_db() -> List[Dict]: |
|
|
"""DB에서 스페이스 로드""" |
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
|
|
|
cursor.execute(''' |
|
|
SELECT space_id, name, author, title, likes, url, sdk, |
|
|
simple_explanation, tech_stack, rank |
|
|
FROM spaces ORDER BY rank ASC LIMIT 30 |
|
|
''') |
|
|
|
|
|
spaces_list = [] |
|
|
for row in cursor.fetchall(): |
|
|
spaces_list.append({ |
|
|
'space_id': row[0], |
|
|
'name': row[1], |
|
|
'author': row[2], |
|
|
'title': row[3], |
|
|
'likes': row[4], |
|
|
'url': row[5], |
|
|
'sdk': row[6], |
|
|
'simple_explanation': row[7], |
|
|
'tech_stack': json.loads(row[8]) if row[8] else [], |
|
|
'rank': row[9], |
|
|
'description': row[3] |
|
|
}) |
|
|
|
|
|
conn.close() |
|
|
return spaces_list |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LLMAnalyzer: |
|
|
"""Fireworks AI (Qwen3) 기반 LLM 분석기""" |
|
|
|
|
|
def __init__(self): |
|
|
self.api_key = os.environ.get('FIREWORKS_API_KEY', '') |
|
|
self.api_url = "https://api.fireworks.ai/inference/v1/chat/completions" |
|
|
self.api_available = bool(self.api_key) |
|
|
|
|
|
if not self.api_available: |
|
|
print("⚠️ FIREWORKS_API_KEY 환경변수가 설정되지 않았습니다. 템플릿 모드로 동작합니다.") |
|
|
|
|
|
def call_llm(self, messages: List[Dict], max_tokens: int = 2000) -> str: |
|
|
"""Fireworks AI API 호출""" |
|
|
if not self.api_available: |
|
|
return None |
|
|
|
|
|
try: |
|
|
payload = { |
|
|
"model": "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507", |
|
|
"max_tokens": max_tokens, |
|
|
"top_p": 1, |
|
|
"top_k": 40, |
|
|
"presence_penalty": 0, |
|
|
"frequency_penalty": 0, |
|
|
"temperature": 0.6, |
|
|
"messages": messages |
|
|
} |
|
|
|
|
|
headers = { |
|
|
"Accept": "application/json", |
|
|
"Content-Type": "application/json", |
|
|
"Authorization": f"Bearer {self.api_key}" |
|
|
} |
|
|
|
|
|
response = requests.post(self.api_url, headers=headers, json=payload, timeout=30) |
|
|
response.raise_for_status() |
|
|
|
|
|
result = response.json() |
|
|
return result['choices'][0]['message']['content'] |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ LLM API 호출 오류: {e}") |
|
|
return None |
|
|
|
|
|
def fetch_model_card(self, model_id: str) -> str: |
|
|
"""허깅페이스 모델 카드(README.md) 가져오기""" |
|
|
try: |
|
|
url = f"https://huggingface.co/{model_id}/raw/main/README.md" |
|
|
response = requests.get(url, timeout=10) |
|
|
|
|
|
if response.status_code == 200: |
|
|
content = response.text |
|
|
|
|
|
if len(content) > 3000: |
|
|
content = content[:3000] + "\n...(후략)" |
|
|
return content |
|
|
else: |
|
|
return None |
|
|
except Exception as e: |
|
|
print(f" ⚠️ 모델 카드 가져오기 오류: {e}") |
|
|
return None |
|
|
|
|
|
def fetch_space_code(self, space_id: str) -> str: |
|
|
"""허깅페이스 스페이스 app.py 가져오기""" |
|
|
try: |
|
|
url = f"https://huggingface.co/spaces/{space_id}/raw/main/app.py" |
|
|
response = requests.get(url, timeout=10) |
|
|
|
|
|
if response.status_code == 200: |
|
|
content = response.text |
|
|
|
|
|
if len(content) > 2000: |
|
|
content = content[:2000] + "\n...(후략)" |
|
|
return content |
|
|
else: |
|
|
return None |
|
|
except Exception as e: |
|
|
print(f" ⚠️ 스페이스 코드 가져오기 오류: {e}") |
|
|
return None |
|
|
|
|
|
def analyze_news_simple(self, title: str, content: str = "") -> Dict: |
|
|
"""뉴스 기사를 중고등학생 수준으로 분석 - LLM API 사용""" |
|
|
|
|
|
|
|
|
if self.api_available: |
|
|
try: |
|
|
messages = [ |
|
|
{ |
|
|
"role": "system", |
|
|
"content": """당신은 중고등학생도 이해할 수 있게 AI 뉴스를 쉽게 설명하는 전문가입니다. |
|
|
한국어로 답변하며, 다음 형식의 JSON으로만 응답하세요: |
|
|
|
|
|
{ |
|
|
"summary": "뉴스 내용을 8-10문장으로 자세하고 구체적으로 설명 (수치, 사실, 배경 포함)", |
|
|
"significance": "이 뉴스가 왜 중요한지 2-3문장으로 설명", |
|
|
"impact_level": "high 또는 medium 또는 low", |
|
|
"impact_text": "높음 또는 중간 또는 낮음", |
|
|
"impact_description": "어떤 영향이 있을지 2-3문장으로 설명", |
|
|
"action": "중고등학생이 할 수 있는 것을 2-3문장으로 제안" |
|
|
} |
|
|
|
|
|
반드시 위 형식의 JSON만 출력하세요.""" |
|
|
}, |
|
|
{ |
|
|
"role": "user", |
|
|
"content": f"""다음 AI 뉴스를 중고등학생이 이해할 수 있도록 분석해주세요: |
|
|
|
|
|
제목: {title} |
|
|
|
|
|
각 항목을 구체적이고 자세하게 작성하되, 중고등학생 수준에 맞춰 쉽게 설명해주세요. |
|
|
특히 summary는 8-10문장으로 충분히 상세하게 작성하세요.""" |
|
|
} |
|
|
] |
|
|
|
|
|
result = self.call_llm(messages, max_tokens=1500) |
|
|
|
|
|
if result: |
|
|
|
|
|
try: |
|
|
|
|
|
result_clean = result.replace('```json', '').replace('```', '').strip() |
|
|
analysis = json.loads(result_clean) |
|
|
|
|
|
|
|
|
required_fields = ['summary', 'significance', 'impact_level', 'impact_text', 'impact_description', 'action'] |
|
|
if all(field in analysis for field in required_fields): |
|
|
print(f" ✅ LLM 분석 성공") |
|
|
return analysis |
|
|
else: |
|
|
print(f" ⚠️ LLM 응답에 필수 필드 누락") |
|
|
except json.JSONDecodeError as e: |
|
|
print(f" ⚠️ JSON 파싱 오류: {e}") |
|
|
print(f" 원본 응답: {result[:200]}...") |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ LLM API 호출 오류: {e}") |
|
|
|
|
|
|
|
|
print(f" ℹ️ 템플릿 모드로 전환") |
|
|
|
|
|
analysis_templates = { |
|
|
"챗GPT": { |
|
|
"summary": """마이크로소프트(MS)는 챗GPT의 폭발적인 사용량 증가로 인해 데이터센터 용량이 부족한 심각한 상황에 직면했습니다. |
|
|
현재 미국 내 여러 핵심 지역에서 물리적 공간과 서버 용량이 모두 한계에 도달한 상태입니다. |
|
|
특히 버지니아와 텍사스 등 주요 클라우드 허브 지역에서는 2026년 상반기까지 신규 Azure 클라우드 구독이 제한될 것으로 예상됩니다. |
|
|
이는 생성형 AI 서비스의 급격한 성장 속도가 기업들의 인프라 준비 능력을 크게 초과하고 있음을 보여줍니다. |
|
|
MS는 데이터센터 확장을 위해 막대한 투자를 하고 있지만, 실제 인프라 구축에는 최소 2-3년이 소요됩니다. |
|
|
이러한 공급 부족 현상은 AI 서비스 가격 상승과 접근성 제한으로 이어질 수 있으며, |
|
|
경쟁사들도 유사한 문제에 직면할 가능성이 높습니다. |
|
|
전문가들은 이 상황이 AI 산업의 성장 속도를 일시적으로 늦출 수 있다고 분석하고 있습니다.""", |
|
|
"significance": "이 뉴스는 AI 기술의 대중화 속도가 기업들의 예상을 훨씬 뛰어넘고 있음을 보여줍니다. MS 같은 글로벌 IT 기업도 AI 수요를 따라잡기 위해 고군분투하고 있으며, 이는 AI가 단순한 유행이 아닌 산업 전반을 변화시키는 핵심 기술임을 증명합니다.", |
|
|
"impact_level": "high", |
|
|
"impact_text": "높음", |
|
|
"impact_description": "클라우드 인프라 부족은 AI 서비스 확장에 직접적인 영향을 미치며, 향후 AI 기술 접근성과 비용 구조를 변화시킬 수 있습니다.", |
|
|
"action": "챗GPT나 Claude 같은 AI 도구를 활용한 학습 방법을 익히세요. 보고서 작성, 코딩 학습, 외국어 공부 등 다양한 분야에서 AI를 학습 보조 도구로 사용할 수 있습니다." |
|
|
}, |
|
|
"GPU": { |
|
|
"summary": """미국 정부가 아랍에미리트(UAE)에 최첨단 AI 칩(GPU) 수출을 공식적으로 승인했습니다. |
|
|
이번 승인은 UAE 내에서 미국 기업이 직접 운영하는 데이터센터에만 한정되며, 특히 오픈AI 전용 5기가와트(GW) 규모의 대형 데이터센터 구축에 사용될 예정입니다. |
|
|
GPU는 AI 모델 학습과 추론에 필수적인 하드웨어로, 수천 개의 연산을 동시에 처리할 수 있는 병렬 처리 능력이 핵심입니다. |
|
|
현재 GPU 시장은 엔비디아가 약 80% 이상을 장악하고 있으며, 특히 AI 전용 H100, H200 칩은 공급 부족 현상이 심각합니다. |
|
|
이번 결정으로 엔비디아의 시가총액이 5조 달러에 근접할 것으로 월스트리트는 전망하고 있습니다. |
|
|
한편, 이는 미국의 전략적 기술 수출 정책 변화를 보여주는 중요한 사례입니다. |
|
|
중국에 대해서는 엄격한 수출 통제를 유지하면서도, 중동의 주요 동맹국에는 선별적으로 허용하는 '기술 동맹' 전략을 구사하고 있습니다.""", |
|
|
"significance": "이는 미국의 AI 기술 수출 정책 변화를 보여주는 중요한 신호입니다. 기술 패권 경쟁 속에서도 전략적 동맹국과의 협력을 통해 AI 생태계를 확장하려는 미국의 의도를 엿볼 수 있습니다.", |
|
|
"impact_level": "medium", |
|
|
"impact_text": "중간", |
|
|
"impact_description": "AI 하드웨어 공급망의 지정학적 변화는 글로벌 AI 산업 지형도에 영향을 미칠 수 있으며, 특히 반도체 산업과 국제 관계에 중요한 의미를 가집니다.", |
|
|
"action": "컴퓨터 하드웨어, 특히 GPU의 작동 원리와 AI 학습에서의 역할을 공부해보세요. 병렬 처리, 행렬 연산 등의 개념을 이해하면 AI 기술의 근간을 파악할 수 있습니다." |
|
|
}, |
|
|
"소라": { |
|
|
"summary": """오픈AI의 혁신적인 AI 동영상 생성 앱 '소라(Sora)'가 출시 단 5일 만에 100만 다운로드를 돌파하는 경이적인 기록을 세웠습니다. |
|
|
이는 전설적인 챗GPT보다도 빠른 성장 속도이며, 초대 전용(invite-only) 방식의 제한적 출시임을 고려하면 더욱 놀라운 성과입니다. |
|
|
소라는 사용자가 입력한 텍스트 프롬프트만으로 최대 1분 길이의 고품질 동영상을 자동으로 생성할 수 있는 생성형 AI 도구입니다. |
|
|
현재 미국과 캐나다에서 iOS 전용으로 먼저 출시되었으며, 안드로이드 버전과 글로벌 확장이 계획되어 있습니다. |
|
|
소라는 기존의 이미지 생성 AI(미드저니, 스테이블 디퓨전 등)를 뛰어넘어, 시간의 흐름과 물리 법칙을 이해하는 수준으로 발전했습니다. |
|
|
예를 들어, 파도가 치는 장면을 생성할 때 물의 움직임, 빛의 반사, 소리까지 자연스럽게 표현할 수 있습니다. |
|
|
이는 영화, 광고, 교육 콘텐츠, 게임 등 모든 영상 산업에 혁명적 변화를 예고합니다.""", |
|
|
"significance": "텍스트를 이미지로 변환하는 기술에서 더 나아가 동영상 생성까지 가능해진 것은 AI 기술의 진화를 보여줍니다. 콘텐츠 제작의 민주화가 가속화되고 있으며, 누구나 쉽게 고품질 영상을 만들 수 있는 시대가 열리고 있습니다.", |
|
|
"impact_level": "high", |
|
|
"impact_text": "높음", |
|
|
"impact_description": "영상 제작 산업의 패러다임이 변화하고 있으며, 교육, 마케팅, 엔터테인먼트 등 다양한 분야에서 AI 동영상 생성 기술의 활용이 증가할 것으로 예상됩니다.", |
|
|
"action": "AI 동영상 생성 도구의 가능성과 한계를 탐구해보세요. 창의적인 아이디어를 시각화하는 방법을 배우고, 동시에 딥페이크 같은 악용 사례에 대한 비판적 사고도 함양하세요." |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
for keyword, template in analysis_templates.items(): |
|
|
if keyword.lower() in title.lower(): |
|
|
return template |
|
|
|
|
|
|
|
|
return { |
|
|
"summary": f"""'{title}'는 최신 AI 기술 동향을 다루는 중요한 뉴스입니다. |
|
|
인공지능 분야는 매일 새로운 발전을 이루고 있으며, 이러한 기술 변화는 우리의 일상생활, 교육, 그리고 미래 직업 세계에 직접적인 영향을 미칠 것으로 예상됩니다. |
|
|
최근 AI 기술은 단순히 데이터를 처리하는 수준을 넘어, 창의적인 콘텐츠를 생성하고 복잡한 문제를 해결하는 단계로 진화하고 있습니다. |
|
|
특히 대규모 언어 모델(LLM)과 생성형 AI의 발전은 산업 전반에 걸쳐 혁신을 가져오고 있습니다. |
|
|
이러한 기술 변화는 새로운 일자리를 창출하는 동시에, 기존 직업의 성격을 변화시키고 있어 우리 모두가 주목해야 할 트렌드입니다. |
|
|
전문가들은 향후 5-10년 내에 AI가 거의 모든 산업 분야에 통합될 것으로 전망하고 있습니다. |
|
|
따라서 관련 기술의 원리를 이해하고, 사회적 파급효과를 함께 고민하는 것이 미래 세대에게 매우 중요한 역량이 될 것입니다.""", |
|
|
"significance": "AI 기술의 발전은 단순한 기술 혁신을 넘어 사회, 경제, 윤리적 측면에서 다양한 논의를 불러일으키고 있습니다. 이러한 변화를 이해하고 대비하는 것이 미래 세대에게 중요한 역량입니다.", |
|
|
"impact_level": "medium", |
|
|
"impact_text": "중간", |
|
|
"impact_description": "AI 기술의 발전은 교육, 취업, 산업 전반에 걸쳐 구조적 변화를 가져올 것이며, 이에 대한 이해와 준비가 필요합니다.", |
|
|
"action": "AI 기술의 기본 원리를 학습하고, 관련 프로그래밍(Python 등)이나 데이터 과학 기초를 공부해보세요. 또한 AI 윤리와 사회적 영향에 대해서도 비판적으로 사고하는 습관을 기르세요." |
|
|
} |
|
|
|
|
|
def analyze_model(self, model_name: str, task: str, downloads: int) -> str: |
|
|
"""허깅페이스 모델 분석 - 모델 카드를 LLM으로 분석""" |
|
|
|
|
|
|
|
|
model_card = self.fetch_model_card(model_name) |
|
|
|
|
|
|
|
|
if model_card and self.api_available: |
|
|
try: |
|
|
messages = [ |
|
|
{ |
|
|
"role": "system", |
|
|
"content": "당신은 중고등학생도 이해할 수 있게 AI 모델을 쉽게 설명하는 전문가입니다. 한국어로 답변하세요." |
|
|
}, |
|
|
{ |
|
|
"role": "user", |
|
|
"content": f"""다음은 허깅페이스 모델 '{model_name}'의 모델 카드입니다: |
|
|
|
|
|
{model_card} |
|
|
|
|
|
이 모델을 중고등학생이 이해할 수 있도록 3-4문장으로 쉽게 설명해주세요. 다음 내용을 포함하세요: |
|
|
1. 이 모델이 무엇을 하는지 |
|
|
2. 어떤 특징이 있는지 |
|
|
3. 누가 사용하면 좋은지 |
|
|
|
|
|
답변은 반드시 3-4문장의 한국어로만 작성하세요.""" |
|
|
} |
|
|
] |
|
|
|
|
|
result = self.call_llm(messages, max_tokens=500) |
|
|
|
|
|
if result: |
|
|
return result.strip() |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ 모델 분석 LLM 오류: {e}") |
|
|
|
|
|
|
|
|
task_explanations = { |
|
|
"text-generation": "글을 자동으로 만들어주는", |
|
|
"image-to-text": "사진을 보고 설명을 써주는", |
|
|
"text-to-image": "글을 읽고 그림을 그려주는", |
|
|
"translation": "다른 언어로 번역해주는", |
|
|
"question-answering": "질문에 답해주는", |
|
|
"summarization": "긴 글을 짧게 요약해주는", |
|
|
"text-classification": "글을 분류해주는", |
|
|
"token-classification": "단어를 분석해주는", |
|
|
"fill-mask": "빈칸을 채워주는" |
|
|
} |
|
|
|
|
|
task_desc = task_explanations.get(task, "특별한 기능을 하는") |
|
|
|
|
|
if downloads > 10000000: |
|
|
popularity = "엄청나게 많은" |
|
|
elif downloads > 1000000: |
|
|
popularity = "아주 많은" |
|
|
elif downloads > 100000: |
|
|
popularity = "많은" |
|
|
else: |
|
|
popularity = "어느 정도" |
|
|
|
|
|
return f"이 모델은 {task_desc} AI예요. {popularity} 사람들이 다운로드해서 사용하고 있어요. {model_name.split('/')[-1]}라는 이름으로 유명해요!" |
|
|
|
|
|
def analyze_space(self, space_name: str, space_id: str, description: str) -> Dict: |
|
|
"""허깅페이스 스페이스 분석 - app.py를 LLM으로 분석""" |
|
|
|
|
|
|
|
|
app_code = self.fetch_space_code(space_id) |
|
|
|
|
|
|
|
|
if app_code and self.api_available: |
|
|
try: |
|
|
messages = [ |
|
|
{ |
|
|
"role": "system", |
|
|
"content": "당신은 중고등학생도 이해할 수 있게 AI 애플리케이션을 쉽게 설명하는 전문가입니다. 한국어로 답변하세요." |
|
|
}, |
|
|
{ |
|
|
"role": "user", |
|
|
"content": f"""다음은 허깅페이스 스페이스 '{space_name}'의 app.py 코드입니다: |
|
|
|
|
|
{app_code} |
|
|
|
|
|
이 앱을 중고등학생이 이해할 수 있도록 3-4문장으로 쉽게 설명해주세요. 다음 내용을 포함하세요: |
|
|
1. 이 앱이 무엇을 하는지 |
|
|
2. 어떤 기술을 사용하는지 |
|
|
3. 어떻게 활용할 수 있는지 |
|
|
|
|
|
답변은 반드시 3-4문장의 한국어로만 작성하세요.""" |
|
|
} |
|
|
] |
|
|
|
|
|
result = self.call_llm(messages, max_tokens=500) |
|
|
|
|
|
if result: |
|
|
|
|
|
tech_stack = [] |
|
|
if 'gradio' in app_code.lower(): |
|
|
tech_stack.append('Gradio') |
|
|
if 'streamlit' in app_code.lower(): |
|
|
tech_stack.append('Streamlit') |
|
|
if 'transformers' in app_code.lower(): |
|
|
tech_stack.append('Transformers') |
|
|
if 'torch' in app_code.lower() or 'pytorch' in app_code.lower(): |
|
|
tech_stack.append('PyTorch') |
|
|
if 'tensorflow' in app_code.lower(): |
|
|
tech_stack.append('TensorFlow') |
|
|
if 'diffusers' in app_code.lower(): |
|
|
tech_stack.append('Diffusers') |
|
|
|
|
|
if not tech_stack: |
|
|
tech_stack = ['Python', 'AI'] |
|
|
|
|
|
return { |
|
|
"simple_explanation": result.strip(), |
|
|
"tech_stack": tech_stack |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ 스페이스 분석 LLM 오류: {e}") |
|
|
|
|
|
|
|
|
return { |
|
|
"simple_explanation": f"{space_name}는 웹브라우저에서 바로 AI를 체험해볼 수 있는 곳이에요. 설치 없이도 사용할 수 있어서 편리해요! 마치 온라인 게임처럼 바로 접속해서 AI를 사용할 수 있답니다.", |
|
|
"tech_stack": ["Python", "Gradio", "Transformers", "PyTorch"] |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdvancedAIAnalyzer: |
|
|
"""LLM 기반 고급 AI 뉴스 분석기""" |
|
|
|
|
|
def __init__(self): |
|
|
self.llm_analyzer = LLMAnalyzer() |
|
|
self.huggingface_data = { |
|
|
"models": [], |
|
|
"spaces": [] |
|
|
} |
|
|
self.news_data = [] |
|
|
|
|
|
def fetch_aitimes_news(self) -> List[Dict]: |
|
|
"""AI Times에서 오늘 날짜 뉴스 크롤링""" |
|
|
print("📰 AI Times 뉴스 수집 중...") |
|
|
|
|
|
|
|
|
urls = [ |
|
|
'https://www.aitimes.com/news/articleList.html?sc_multi_code=S2&view_type=sm', |
|
|
'https://www.aitimes.com/news/articleList.html?sc_section_code=S1N24&view_type=sm' |
|
|
] |
|
|
|
|
|
all_news = [] |
|
|
today = datetime.now().strftime('%m-%d') |
|
|
yesterday = (datetime.now() - timedelta(days=1)).strftime('%m-%d') |
|
|
|
|
|
for url_idx, url in enumerate(urls, 1): |
|
|
try: |
|
|
print(f" 🔍 [{url_idx}/2] 수집 중: {url}") |
|
|
response = requests.get(url, timeout=15, headers={ |
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' |
|
|
}) |
|
|
response.raise_for_status() |
|
|
response.encoding = 'utf-8' |
|
|
|
|
|
soup = BeautifulSoup(response.text, 'html.parser') |
|
|
|
|
|
|
|
|
articles = soup.find_all('a', href=re.compile(r'/news/articleView\.html\?idxno=\d+')) |
|
|
|
|
|
print(f" → {len(articles)}개 링크 발견") |
|
|
|
|
|
articles_found = 0 |
|
|
for article_tag in articles: |
|
|
try: |
|
|
|
|
|
title = article_tag.get_text(strip=True) |
|
|
link = article_tag.get('href', '') |
|
|
|
|
|
|
|
|
if link and not link.startswith('http'): |
|
|
if link.startswith('/'): |
|
|
link = 'https://www.aitimes.com' + link |
|
|
else: |
|
|
link = 'https://www.aitimes.com/' + link |
|
|
|
|
|
|
|
|
if not title or len(title) < 10: |
|
|
continue |
|
|
|
|
|
|
|
|
parent = article_tag.parent |
|
|
date_text = '' |
|
|
|
|
|
|
|
|
if parent: |
|
|
parent_text = parent.get_text() |
|
|
date_match = re.search(r'(\d{2}-\d{2}\s+\d{2}:\d{2})', parent_text) |
|
|
if date_match: |
|
|
date_text = date_match.group(1) |
|
|
|
|
|
|
|
|
if not date_text: |
|
|
for sibling in article_tag.find_next_siblings(): |
|
|
sibling_text = sibling.get_text() |
|
|
date_match = re.search(r'(\d{2}-\d{2}\s+\d{2}:\d{2})', sibling_text) |
|
|
if date_match: |
|
|
date_text = date_match.group(1) |
|
|
break |
|
|
|
|
|
|
|
|
if not date_text: |
|
|
date_text = today |
|
|
|
|
|
|
|
|
if today not in date_text and yesterday not in date_text: |
|
|
continue |
|
|
|
|
|
news_item = { |
|
|
'title': title, |
|
|
'url': link, |
|
|
'date': date_text, |
|
|
'source': 'AI Times', |
|
|
'category': 'AI' |
|
|
} |
|
|
|
|
|
all_news.append(news_item) |
|
|
articles_found += 1 |
|
|
|
|
|
print(f" ✓ 추가: {title[:60]}... ({date_text})") |
|
|
|
|
|
except Exception as e: |
|
|
continue |
|
|
|
|
|
print(f" → {articles_found}개 최근 기사 수집\n") |
|
|
time.sleep(1) |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ URL 수집 오류: {e}\n") |
|
|
continue |
|
|
|
|
|
|
|
|
unique_news = [] |
|
|
seen_urls = set() |
|
|
for news in all_news: |
|
|
if news['url'] not in seen_urls: |
|
|
unique_news.append(news) |
|
|
seen_urls.add(news['url']) |
|
|
|
|
|
print(f"✅ 총 {len(unique_news)}개 중복 제거된 최근 뉴스\n") |
|
|
|
|
|
|
|
|
if len(unique_news) < 3: |
|
|
print("⚠️ 뉴스가 부족하여 최근 샘플 추가\n") |
|
|
sample_news = [ |
|
|
{ |
|
|
'title': 'MS "챗GPT 수요 폭증으로 데이터센터 부족...2026년까지 지속"', |
|
|
'url': 'https://www.aitimes.com/news/articleView.html?idxno=203055', |
|
|
'date': '10-10 15:10', |
|
|
'source': 'AI Times', |
|
|
'category': 'AI' |
|
|
}, |
|
|
{ |
|
|
'title': '미국, UAE에 GPU 판매 일부 승인...엔비디아 시총 5조달러 눈앞', |
|
|
'url': 'https://www.aitimes.com/news/articleView.html?idxno=203053', |
|
|
'date': '10-10 14:46', |
|
|
'source': 'AI Times', |
|
|
'category': 'AI' |
|
|
}, |
|
|
{ |
|
|
'title': '소라, 챗GPT보다 빨리 100만 다운로드 돌파', |
|
|
'url': 'https://www.aitimes.com/news/articleView.html?idxno=203045', |
|
|
'date': '10-10 12:55', |
|
|
'source': 'AI Times', |
|
|
'category': 'AI' |
|
|
} |
|
|
] |
|
|
for sample in sample_news: |
|
|
if sample['url'] not in seen_urls: |
|
|
unique_news.append(sample) |
|
|
|
|
|
return unique_news[:20] |
|
|
|
|
|
def fetch_hackernews(self, limit: int = 15) -> List[Dict]: |
|
|
"""Hacker News Top Stories 수집 (미국 시간 기준 24시간 이내)""" |
|
|
print(f"🔥 Hacker News Top {limit}개 스토리 수집 중...") |
|
|
|
|
|
news_list = [] |
|
|
|
|
|
try: |
|
|
|
|
|
topstories_url = "https://hacker-news.firebaseio.com/v0/topstories.json" |
|
|
response = requests.get(topstories_url, timeout=10) |
|
|
response.raise_for_status() |
|
|
|
|
|
story_ids = response.json()[:limit * 3] |
|
|
print(f" 📊 {len(story_ids)}개 스토리 ID 받음") |
|
|
|
|
|
|
|
|
current_time = datetime.utcnow() |
|
|
cutoff_time = current_time - timedelta(hours=36) |
|
|
|
|
|
|
|
|
for idx, story_id in enumerate(story_ids, 1): |
|
|
try: |
|
|
if len(news_list) >= limit: |
|
|
break |
|
|
|
|
|
item_url = f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json" |
|
|
item_response = requests.get(item_url, timeout=5) |
|
|
item_response.raise_for_status() |
|
|
|
|
|
story = item_response.json() |
|
|
|
|
|
|
|
|
if story.get('type') != 'story': |
|
|
continue |
|
|
|
|
|
|
|
|
if not story.get('url'): |
|
|
continue |
|
|
|
|
|
|
|
|
timestamp = story.get('time', 0) |
|
|
story_time = datetime.utcfromtimestamp(timestamp) |
|
|
|
|
|
if story_time < cutoff_time: |
|
|
continue |
|
|
|
|
|
|
|
|
date_str = story_time.strftime('%m-%d %H:%M') |
|
|
|
|
|
news_item = { |
|
|
'title': story.get('title', 'No Title'), |
|
|
'url': story.get('url', ''), |
|
|
'date': date_str, |
|
|
'source': 'Hacker News', |
|
|
'category': 'Tech', |
|
|
'score': story.get('score', 0), |
|
|
'author': story.get('by', 'Unknown'), |
|
|
'comments': story.get('descendants', 0) |
|
|
} |
|
|
|
|
|
news_list.append(news_item) |
|
|
|
|
|
print(f" ✓ [{len(news_list)}/{limit}] {news_item['title'][:60]}... (점수: {news_item['score']})") |
|
|
|
|
|
|
|
|
time.sleep(0.2) |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ 스토리 {story_id} 처리 오류: {e}") |
|
|
continue |
|
|
|
|
|
print(f"✅ {len(news_list)}개 Hacker News 스토리 수집 완료\n") |
|
|
return news_list |
|
|
|
|
|
except Exception as e: |
|
|
print(f"❌ Hacker News 수집 오류: {e}\n") |
|
|
return [] |
|
|
|
|
|
def fetch_all_news_sources(self) -> List[Dict]: |
|
|
"""모든 뉴스 소스에서 수집""" |
|
|
print("🌐 여러 소스에서 뉴스 수집 중...\n") |
|
|
|
|
|
all_news = [] |
|
|
|
|
|
|
|
|
aitimes_news = self.fetch_aitimes_news() |
|
|
all_news.extend(aitimes_news) |
|
|
|
|
|
|
|
|
hackernews = self.fetch_hackernews(limit=15) |
|
|
all_news.extend(hackernews) |
|
|
|
|
|
print(f"\n📰 총 {len(all_news)}개 뉴스 수집 완료") |
|
|
print(f" - AI Times: {len(aitimes_news)}개") |
|
|
print(f" - Hacker News: {len(hackernews)}개\n") |
|
|
|
|
|
return all_news |
|
|
|
|
|
def fetch_huggingface_models(self, limit: int = 30) -> List[Dict]: |
|
|
"""허깅페이스 트렌딩 모델 30개 수집 (실제 API)""" |
|
|
print(f"🤗 허깅페이스 트렌딩 모델 {limit}개 수집 중...") |
|
|
|
|
|
models_list = [] |
|
|
|
|
|
try: |
|
|
|
|
|
api = HfApi() |
|
|
|
|
|
|
|
|
models = list(api.list_models( |
|
|
sort="trending_score", |
|
|
direction=-1, |
|
|
limit=limit |
|
|
)) |
|
|
|
|
|
print(f"📊 API에서 {len(models)}개 모델 받음") |
|
|
|
|
|
for idx, model in enumerate(models[:limit], 1): |
|
|
try: |
|
|
model_info = { |
|
|
'name': model.id, |
|
|
'downloads': getattr(model, 'downloads', 0) or 0, |
|
|
'likes': getattr(model, 'likes', 0) or 0, |
|
|
'task': getattr(model, 'pipeline_tag', 'N/A') or 'N/A', |
|
|
'url': f"https://huggingface.co/{model.id}", |
|
|
'rank': idx |
|
|
} |
|
|
|
|
|
|
|
|
print(f" 🔍 {idx}. {model.id} 분석 중...") |
|
|
model_info['analysis'] = self.llm_analyzer.analyze_model( |
|
|
model_info['name'], |
|
|
model_info['task'], |
|
|
model_info['downloads'] |
|
|
) |
|
|
|
|
|
models_list.append(model_info) |
|
|
|
|
|
|
|
|
time.sleep(0.5) |
|
|
|
|
|
|
|
|
if idx % 5 == 0: |
|
|
print(f" ✓ {idx}개 모델 처리 완료...") |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ 모델 {idx} 처리 오류: {e}") |
|
|
continue |
|
|
|
|
|
print(f"✅ {len(models_list)}개 트렌딩 모델 수집 완료\n") |
|
|
|
|
|
|
|
|
if models_list: |
|
|
save_models_to_db(models_list) |
|
|
|
|
|
return models_list |
|
|
|
|
|
except Exception as e: |
|
|
print(f"❌ 모델 수집 오류: {e}") |
|
|
print("💾 DB에서 이전 데이터 로드 시도...\n") |
|
|
return load_models_from_db() |
|
|
|
|
|
def fetch_huggingface_spaces(self, limit: int = 30) -> List[Dict]: |
|
|
"""허깅페이스 트렌딩 스페이스 30개 수집 (실제 API)""" |
|
|
print(f"🚀 허깅페이스 트렌딩 스페이스 {limit}개 수집 중...") |
|
|
|
|
|
spaces_list = [] |
|
|
|
|
|
try: |
|
|
|
|
|
api = HfApi() |
|
|
|
|
|
|
|
|
spaces = list(api.list_spaces( |
|
|
sort="trending_score", |
|
|
direction=-1, |
|
|
limit=limit |
|
|
)) |
|
|
|
|
|
print(f"📊 API에서 {len(spaces)}개 스페이스 받음") |
|
|
|
|
|
for idx, space in enumerate(spaces[:limit], 1): |
|
|
try: |
|
|
space_info = { |
|
|
'space_id': space.id, |
|
|
'name': space.id.split('/')[-1] if '/' in space.id else space.id, |
|
|
'author': space.author, |
|
|
'title': getattr(space, 'title', space.id) or space.id, |
|
|
'likes': getattr(space, 'likes', 0) or 0, |
|
|
'url': f"https://huggingface.co/spaces/{space.id}", |
|
|
'sdk': getattr(space, 'sdk', 'gradio') or 'gradio', |
|
|
'rank': idx |
|
|
} |
|
|
|
|
|
|
|
|
print(f" 🔍 {idx}. {space.id} 분석 중...") |
|
|
space_analysis = self.llm_analyzer.analyze_space( |
|
|
space_info['name'], |
|
|
space_info['space_id'], |
|
|
space_info['title'] |
|
|
) |
|
|
|
|
|
space_info['simple_explanation'] = space_analysis['simple_explanation'] |
|
|
space_info['tech_stack'] = space_analysis['tech_stack'] |
|
|
space_info['description'] = space_info['title'] |
|
|
|
|
|
spaces_list.append(space_info) |
|
|
|
|
|
|
|
|
time.sleep(0.5) |
|
|
|
|
|
|
|
|
if idx % 5 == 0: |
|
|
print(f" ✓ {idx}개 스페이스 처리 완료...") |
|
|
|
|
|
except Exception as e: |
|
|
print(f" ⚠️ 스페이스 {idx} 처리 오류: {e}") |
|
|
continue |
|
|
|
|
|
print(f"✅ {len(spaces_list)}개 트렌딩 스페이스 수집 완료\n") |
|
|
|
|
|
|
|
|
if spaces_list: |
|
|
save_spaces_to_db(spaces_list) |
|
|
|
|
|
return spaces_list |
|
|
|
|
|
except Exception as e: |
|
|
print(f"❌ 스페이스 수집 오류: {e}") |
|
|
print("💾 DB에서 이전 데이터 로드 시도...\n") |
|
|
return load_spaces_from_db() |
|
|
|
|
|
def analyze_all_news(self) -> List[Dict]: |
|
|
"""모든 뉴스에 LLM 분석 추가""" |
|
|
print("📰 뉴스 LLM 분석 시작...\n") |
|
|
|
|
|
|
|
|
news = self.fetch_all_news_sources() |
|
|
|
|
|
if not news: |
|
|
print("⚠️ 수집된 뉴스가 없습니다.") |
|
|
return [] |
|
|
|
|
|
analyzed_news = [] |
|
|
|
|
|
for idx, article in enumerate(news, 1): |
|
|
print(f" 🧠 {idx}/{len(news)}: {article['title'][:50]}... 분석 중") |
|
|
|
|
|
analysis = self.llm_analyzer.analyze_news_simple( |
|
|
article['title'], |
|
|
"" |
|
|
) |
|
|
|
|
|
article['analysis'] = analysis |
|
|
analyzed_news.append(article) |
|
|
|
|
|
|
|
|
time.sleep(0.3) |
|
|
|
|
|
print(f"\n✅ {len(analyzed_news)}개 뉴스 분석 완료\n") |
|
|
|
|
|
|
|
|
if analyzed_news: |
|
|
save_news_to_db(analyzed_news) |
|
|
|
|
|
return analyzed_news |
|
|
|
|
|
def get_all_data(self, force_refresh: bool = False) -> Dict: |
|
|
"""모든 데이터 수집 및 분석 |
|
|
|
|
|
Args: |
|
|
force_refresh: True면 새로 수집, False면 DB에서 로드 후 없으면 수집 |
|
|
""" |
|
|
print("\n" + "="*60) |
|
|
print("🚀 데일리 AI 탑 100 시스템 시작") |
|
|
print("="*60 + "\n") |
|
|
|
|
|
if force_refresh: |
|
|
print("🔄 강제 새로고침 모드: 모든 데이터 새로 수집\n") |
|
|
analyzed_news = self.analyze_all_news() |
|
|
analyzed_models = self.fetch_huggingface_models(30) |
|
|
analyzed_spaces = self.fetch_huggingface_spaces(30) |
|
|
else: |
|
|
print("💾 DB 우선 로드 모드\n") |
|
|
|
|
|
|
|
|
analyzed_news = load_news_from_db() |
|
|
if not analyzed_news: |
|
|
print("📰 DB에 뉴스 없음 → 새로 수집") |
|
|
analyzed_news = self.analyze_all_news() |
|
|
else: |
|
|
print(f"✅ DB에서 {len(analyzed_news)}개 뉴스 로드\n") |
|
|
|
|
|
analyzed_models = load_models_from_db() |
|
|
if not analyzed_models: |
|
|
print("🤗 DB에 모델 없음 → 새로 수집") |
|
|
analyzed_models = self.fetch_huggingface_models(30) |
|
|
else: |
|
|
print(f"✅ DB에서 {len(analyzed_models)}개 모델 로드\n") |
|
|
|
|
|
analyzed_spaces = load_spaces_from_db() |
|
|
if not analyzed_spaces: |
|
|
print("🚀 DB에 스페이스 없음 → 새로 수집") |
|
|
analyzed_spaces = self.fetch_huggingface_spaces(30) |
|
|
else: |
|
|
print(f"✅ DB에서 {len(analyzed_spaces)}개 스페이스 로드\n") |
|
|
|
|
|
|
|
|
stats = { |
|
|
'total_news': len(analyzed_news), |
|
|
'hf_models': len(analyzed_models), |
|
|
'hf_spaces': len(analyzed_spaces), |
|
|
'llm_analyses': len(analyzed_news) + len(analyzed_models) + len(analyzed_spaces) |
|
|
} |
|
|
|
|
|
print(f"\n✅ 전체 분석 완료: {stats['llm_analyses']}개 항목") |
|
|
print(f" 📰 뉴스: {stats['total_news']}개") |
|
|
print(f" 🤗 모델: {stats['hf_models']}개") |
|
|
print(f" 🚀 스페이스: {stats['hf_spaces']}개\n") |
|
|
|
|
|
return { |
|
|
'analyzed_news': analyzed_news, |
|
|
'analyzed_models': analyzed_models, |
|
|
'analyzed_spaces': analyzed_spaces, |
|
|
'stats': stats, |
|
|
'timestamp': datetime.now().strftime('%Y년 %m월 %d일 %H:%M:%S') |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
"""메인 페이지""" |
|
|
try: |
|
|
|
|
|
force_refresh = request.args.get('refresh', 'false').lower() == 'true' |
|
|
|
|
|
analyzer = AdvancedAIAnalyzer() |
|
|
data = analyzer.get_all_data(force_refresh=force_refresh) |
|
|
return render_template_string(HTML_TEMPLATE, **data) |
|
|
except Exception as e: |
|
|
import traceback |
|
|
error_detail = traceback.format_exc() |
|
|
return f""" |
|
|
<html> |
|
|
<body style="font-family: Arial; padding: 50px; text-align: center;"> |
|
|
<h1 style="color: #e74c3c;">⚠️ 오류 발생</h1> |
|
|
<p>{str(e)}</p> |
|
|
<pre style="text-align: left; background: #f5f5f5; padding: 20px; border-radius: 5px;"> |
|
|
{error_detail} |
|
|
</pre> |
|
|
<button onclick="location.href='/'" style="padding: 10px 20px; margin: 10px;"> |
|
|
🔄 새로고침 |
|
|
</button> |
|
|
<button onclick="location.href='/?refresh=true'" style="padding: 10px 20px; margin: 10px; background: #ff6b6b; color: white; border: none; border-radius: 5px;"> |
|
|
🔥 강제 갱신 |
|
|
</button> |
|
|
</body> |
|
|
</html> |
|
|
""", 500 |
|
|
|
|
|
|
|
|
@app.route('/api/data') |
|
|
def api_data(): |
|
|
"""JSON API""" |
|
|
try: |
|
|
force_refresh = request.args.get('refresh', 'false').lower() == 'true' |
|
|
analyzer = AdvancedAIAnalyzer() |
|
|
data = analyzer.get_all_data(force_refresh=force_refresh) |
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'data': data |
|
|
}) |
|
|
except Exception as e: |
|
|
return jsonify({ |
|
|
'success': False, |
|
|
'error': str(e) |
|
|
}), 500 |
|
|
|
|
|
|
|
|
@app.route('/api/refresh') |
|
|
def api_refresh(): |
|
|
"""강제 새로고침 API""" |
|
|
try: |
|
|
analyzer = AdvancedAIAnalyzer() |
|
|
data = analyzer.get_all_data(force_refresh=True) |
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'message': '데이터가 성공적으로 갱신되었습니다', |
|
|
'stats': data['stats'] |
|
|
}) |
|
|
except Exception as e: |
|
|
return jsonify({ |
|
|
'success': False, |
|
|
'error': str(e) |
|
|
}), 500 |
|
|
|
|
|
|
|
|
@app.route('/health') |
|
|
def health(): |
|
|
"""헬스 체크""" |
|
|
try: |
|
|
|
|
|
conn = sqlite3.connect(DB_PATH) |
|
|
cursor = conn.cursor() |
|
|
cursor.execute("SELECT COUNT(*) FROM news") |
|
|
news_count = cursor.fetchone()[0] |
|
|
cursor.execute("SELECT COUNT(*) FROM models") |
|
|
models_count = cursor.fetchone()[0] |
|
|
cursor.execute("SELECT COUNT(*) FROM spaces") |
|
|
spaces_count = cursor.fetchone()[0] |
|
|
conn.close() |
|
|
|
|
|
return jsonify({ |
|
|
"status": "healthy", |
|
|
"service": "데일리 AI 탑 100", |
|
|
"version": "3.3.0", |
|
|
"database": { |
|
|
"connected": True, |
|
|
"news_count": news_count, |
|
|
"models_count": models_count, |
|
|
"spaces_count": spaces_count |
|
|
}, |
|
|
"fireworks_api": { |
|
|
"configured": bool(os.environ.get('FIREWORKS_API_KEY')) |
|
|
}, |
|
|
"timestamp": datetime.now().isoformat() |
|
|
}) |
|
|
except Exception as e: |
|
|
return jsonify({ |
|
|
"status": "unhealthy", |
|
|
"error": str(e) |
|
|
}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
port = int(os.environ.get('PORT', 7860)) |
|
|
|
|
|
print(f""" |
|
|
╔════════════════════════════════════════════════════════════╗ |
|
|
║ ║ |
|
|
║ 🤖 투데이 AI ║ |
|
|
║ ║ |
|
|
╚════════════════════════════════════════════════════════════╝ |
|
|
|
|
|
📌 매일 아침 전 세계 AI 생태계의 핵심 100가지를 한눈에! |
|
|
최신 뉴스·모델·서비스를 AI가 직접 분석해서 쉽게 설명합니다. |
|
|
|
|
|
✨ 주요 기능: |
|
|
• 💾 SQLite DB 영구 스토리지 |
|
|
• 🌐 AI Times 실시간 뉴스 크롤링 (오늘+어제) |
|
|
• 🔥 Hacker News Top Stories (36시간 이내) |
|
|
• 📰 뉴스 중고등학생 수준 LLM 분석 |
|
|
• 🤗 허깅페이스 트렌딩 모델 TOP 30 (모델 카드 분석) |
|
|
• 🚀 허깅페이스 트렌딩 스페이스 TOP 30 (app.py 분석) |
|
|
• 🧠 Fireworks AI (Qwen3-235B) 실시간 LLM 분석 |
|
|
• 🎨 탭 UI (뉴스/모델/스페이스) |
|
|
|
|
|
🔑 API 설정: |
|
|
FIREWORKS_API_KEY: {"✅ 설정됨" if os.environ.get('FIREWORKS_API_KEY') else "❌ 미설정 (템플릿 모드)"} |
|
|
|
|
|
🚀 서버 정보: |
|
|
📍 메인: http://localhost:{port} |
|
|
🔄 강제갱신: http://localhost:{port}/?refresh=true |
|
|
📊 API: http://localhost:{port}/api/data |
|
|
🔥 새로고침 API: http://localhost:{port}/api/refresh |
|
|
💚 Health: http://localhost:{port}/health |
|
|
|
|
|
💾 데이터베이스: {DB_PATH} |
|
|
|
|
|
초기화 중... |
|
|
""") |
|
|
|
|
|
|
|
|
try: |
|
|
init_database() |
|
|
except Exception as e: |
|
|
print(f"❌ DB 초기화 오류: {e}") |
|
|
sys.exit(1) |
|
|
|
|
|
print("\n✅ 서버 준비 완료!") |
|
|
print("브라우저에서 위 URL을 열어주세요!") |
|
|
print("종료: Ctrl+C\n") |
|
|
|
|
|
try: |
|
|
app.run( |
|
|
host='0.0.0.0', |
|
|
port=port, |
|
|
debug=False, |
|
|
threaded=True |
|
|
) |
|
|
except KeyboardInterrupt: |
|
|
print("\n\n👋 서버 종료!") |
|
|
sys.exit(0) |
|
|
except Exception as e: |
|
|
print(f"\n❌서버 오류: {e}") |
|
|
sys.exit(1) |