| |
|
|
| 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 호출 — 다중 모델 fallback (404 등 모델 deprecate 대응)""" |
| if not self.api_available: |
| return None |
|
|
| |
| model_candidates = [ |
| "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507", |
| "accounts/fireworks/models/qwen3-235b-a22b", |
| "accounts/fireworks/models/qwen3-30b-a3b-instruct-2507", |
| "accounts/fireworks/models/qwen2p5-72b-instruct", |
| "accounts/fireworks/models/llama-v3p3-70b-instruct", |
| "accounts/fireworks/models/deepseek-v3-0324", |
| ] |
|
|
| headers = { |
| "Accept": "application/json", |
| "Content-Type": "application/json", |
| "Authorization": f"Bearer {self.api_key}" |
| } |
|
|
| last_err = None |
| for model in model_candidates: |
| try: |
| payload = { |
| "model": model, |
| "max_tokens": max_tokens, |
| "top_p": 1, |
| "top_k": 40, |
| "presence_penalty": 0, |
| "frequency_penalty": 0, |
| "temperature": 0.6, |
| "messages": messages |
| } |
|
|
| response = requests.post(self.api_url, headers=headers, json=payload, timeout=30) |
| |
| if response.status_code == 404: |
| last_err = f"404 {model}" |
| continue |
| response.raise_for_status() |
|
|
| result = response.json() |
| return result['choices'][0]['message']['content'] |
|
|
| except requests.exceptions.HTTPError as e: |
| |
| if e.response is not None and e.response.status_code in (400, 404, 410): |
| last_err = f"{e.response.status_code} {model}" |
| continue |
| last_err = str(e) |
| break |
| except Exception as e: |
| last_err = str(e) |
| break |
|
|
| print(f" ⚠️ LLM API 호출 오류: 모든 모델 fallback 실패 (last: {last_err})") |
| 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: |
| """뉴스 기사 분석 — 친절한 AI 전문가 톤 (LLM API 사용)""" |
|
|
| |
| if self.api_available: |
| try: |
| messages = [ |
| { |
| "role": "system", |
| "content": """당신은 친절한 AI 전문가입니다. AI 산업의 최신 뉴스를 핵심을 짚어 명확하고 차분하게 설명하되, 누구나 이해할 수 있도록 친근한 한국어 어조를 유지합니다. |
| |
| - 어조: 정중한 평어체 (~합니다 / ~입니다). "~예요", "~답니다", "~네요" 같은 구어체 어미는 피합니다. |
| - 깊이: 기술적 핵심 + 시장·산업적 함의 + 일반 독자가 알아두면 좋은 맥락 균형 |
| - 가독성: 짧은 문장, 한 문장 한 메시지. 전문 용어가 나오면 1줄 풀이를 덧붙입니다. |
| |
| 다음 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} |
| |
| 각 항목을 구체적이고 자세하게 작성하되, 친절한 AI 전문가의 톤으로 명확하게 설명해주세요. |
| 특히 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의 기본 원리(LLM, RAG, 에이전트 등)와 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 전문가입니다. AI 모델의 핵심을 정확하고 명확하게 설명하되, 누구나 이해할 수 있는 친근한 한국어 어조를 유지합니다. 정중한 평어체 (~합니다 / ~입니다)를 사용하고, '~예요', '~답니다' 같은 구어체 어미는 피합니다." |
| }, |
| { |
| "role": "user", |
| "content": f"""다음은 허깅페이스 모델 '{model_name}'의 모델 카드입니다: |
| |
| {model_card} |
| |
| 이 모델을 친절한 AI 전문가의 톤으로 3-4문장으로 설명해주세요. 다음을 포함하세요: |
| 1. 이 모델이 어떤 작업(task)을 수행하는지 — 핵심 기능 |
| 2. 기술적 특징·강점 — 아키텍처·파라미터 규모·차별점 (전문 용어는 1줄 풀이) |
| 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 모델입니다. 누적 다운로드 {downloads:,}회를 기록하며 {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 전문가입니다. AI 애플리케이션의 핵심 기능과 기술 스택을 정확하고 명확하게 설명하되, 누구나 이해할 수 있는 친근한 한국어 어조를 유지합니다. 정중한 평어체 (~합니다 / ~입니다)를 사용하고, '~예요', '~답니다' 같은 구어체 어미는 피합니다." |
| }, |
| { |
| "role": "user", |
| "content": f"""다음은 허깅페이스 스페이스 '{space_name}'의 app.py 코드입니다: |
| |
| {app_code} |
| |
| 이 앱을 친절한 AI 전문가의 톤으로 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 _fallback_model_analysis(self, model_info: Dict) -> str: |
| """LLM 호출 실패/지연 시 사용할 즉시 fallback 설명 — 친절한 AI 전문가 톤""" |
| name = model_info['name'].split('/')[-1] |
| task = model_info.get('task', 'N/A') or 'N/A' |
| dl = model_info.get('downloads', 0) |
| task_kr = { |
| 'text-generation': '텍스트를 자동 생성하는 언어 모델', |
| 'image-to-text': '이미지를 분석하여 텍스트 설명을 생성하는 모델', |
| 'text-to-image': '텍스트 프롬프트로부터 이미지를 생성하는 확산(diffusion) 모델', |
| 'image-text-to-text': '이미지와 텍스트를 함께 처리하는 멀티모달 모델', |
| 'text-to-speech': '텍스트를 자연스러운 음성으로 변환하는 TTS 모델', |
| 'automatic-speech-recognition': '음성을 텍스트로 변환하는 ASR 모델', |
| 'translation': '언어 간 번역을 수행하는 모델', |
| 'question-answering': '문서 기반 질의응답을 수행하는 모델', |
| 'summarization': '긴 문서를 요약하는 모델', |
| 'text-classification': '텍스트를 분류하는 모델', |
| 'feature-extraction': '의미 벡터(임베딩)를 추출하는 모델', |
| 'sentence-similarity': '문장 간 의미 유사도를 계산하는 모델', |
| 'fill-mask': '마스킹된 토큰을 예측하는 모델', |
| 'text-to-video': '텍스트 프롬프트로부터 동영상을 생성하는 모델', |
| 'image-text-to-image': '이미지·텍스트 입력으로부터 이미지를 생성/편집하는 모델', |
| 'any-to-any': '여러 modality 입출력을 지원하는 통합 모델', |
| }.get(task, '특정 작업에 특화된 AI 모델') |
| if dl > 10_000_000: pop = '광범위한 사용자층이' |
| elif dl > 1_000_000: pop = '많은 개발자가' |
| elif dl > 100_000: pop = '활발히' |
| elif dl > 10_000: pop = '관심 있는 사용자들이' |
| else: pop = '초기 사용자 중심으로' |
| return f"이 모델은 {task_kr}입니다. 누적 다운로드 {dl:,}회를 기록하며 {pop} 활용 중인 모델이며, 'FINAL-Bench 컬렉션' 외부 커뮤니티에서도 폭넓게 검증되고 있습니다. '{name}'(이)라는 이름으로 공개되어 있으며, 위 작업이 필요한 프로젝트에서 후보로 검토하기 적합합니다." |
|
|
| def _fallback_space_analysis(self, space_info: Dict) -> Dict: |
| """LLM 실패 시 fallback — 친절한 AI 전문가 톤""" |
| name = space_info.get('name', 'Space') |
| sdk = space_info.get('sdk', 'gradio') or 'gradio' |
| tech = ['Python', sdk.capitalize() if sdk else 'Gradio'] |
| return { |
| 'simple_explanation': f"'{name}'은(는) 웹 브라우저에서 바로 실행되는 인터랙티브 AI 데모입니다. 별도 설치 없이 즉시 입력·출력을 시도하며 모델의 동작을 확인할 수 있고, {sdk.capitalize() if sdk else 'Gradio'} 기반으로 구축되어 있습니다.", |
| 'tech_stack': tech, |
| } |
|
|
| def fetch_huggingface_models(self, limit: int = 30) -> List[Dict]: |
| """허깅페이스 트렌딩 모델 수집 — 2-phase (기본정보 즉시 저장 → LLM은 best-effort)""" |
| print(f"🤗 허깅페이스 트렌딩 모델 {limit}개 수집 중...") |
|
|
| models_list = [] |
|
|
| |
| try: |
| api = HfApi() |
| |
| try: |
| models = list(api.list_models( |
| sort="trending_score", |
| limit=limit |
| )) |
| except (TypeError, Exception) as e_sort: |
| print(f" ⚠️ trending_score sort 미지원 ({e_sort}), downloads로 fallback") |
| try: |
| models = list(api.list_models( |
| sort="downloads", |
| limit=limit |
| )) |
| except Exception as e2: |
| print(f" ⚠️ downloads sort도 실패 ({e2}), 기본 list로 fallback") |
| models = list(api.list_models(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, |
| 'analysis': '' |
| } |
| |
| model_info['analysis'] = self._fallback_model_analysis(model_info) |
| models_list.append(model_info) |
| except Exception as e: |
| print(f" ⚠️ 모델 {idx} 기본정보 오류: {e}") |
| continue |
|
|
| print(f"✅ PHASE 1 완료: {len(models_list)}개 기본정보 확보") |
|
|
| |
| if models_list: |
| try: |
| save_models_to_db(models_list) |
| print(f"💾 DB 저장 완료 (PHASE 1)") |
| except Exception as e: |
| print(f" ⚠️ DB 저장 오류 (PHASE 1): {e}") |
| except Exception as e: |
| print(f"❌ HF API 모델 fetch 오류: {e}") |
| print("💾 DB에서 이전 데이터 로드 시도...\n") |
| return load_models_from_db() |
|
|
| |
| if not getattr(self.llm_analyzer, 'api_available', False): |
| print(f"ℹ️ LLM API 미활성 → PHASE 2 건너뜀, fallback 설명만 사용\n") |
| return models_list |
|
|
| print(f"🔍 PHASE 2: LLM 분석 시도 (best-effort, 실패해도 fallback 유지)") |
| enriched = 0 |
| for idx, m in enumerate(models_list, 1): |
| try: |
| llm_result = self.llm_analyzer.analyze_model(m['name'], m['task'], m['downloads']) |
| if llm_result and len(llm_result.strip()) > 10: |
| m['analysis'] = llm_result |
| enriched += 1 |
| if idx % 5 == 0: |
| print(f" ✓ {idx}/{len(models_list)} 처리 ({enriched} 보강 완료)") |
| except Exception as e: |
| |
| continue |
| print(f"✅ PHASE 2 완료: {enriched}/{len(models_list)} LLM 보강\n") |
|
|
| |
| try: |
| save_models_to_db(models_list) |
| except Exception as e: |
| print(f" ⚠️ DB 최종 저장 오류: {e}") |
|
|
| return models_list |
|
|
| def fetch_huggingface_spaces(self, limit: int = 30) -> List[Dict]: |
| """허깅페이스 트렌딩 스페이스 수집 — 2-phase (기본정보 즉시 저장 → LLM은 best-effort)""" |
| print(f"🚀 허깅페이스 트렌딩 스페이스 {limit}개 수집 중...") |
|
|
| spaces_list = [] |
|
|
| |
| try: |
| api = HfApi() |
| try: |
| spaces = list(api.list_spaces( |
| sort="trending_score", |
| limit=limit |
| )) |
| except (TypeError, Exception) as e_sort: |
| print(f" ⚠️ trending_score sort 미지원 ({e_sort}), likes로 fallback") |
| try: |
| spaces = list(api.list_spaces( |
| sort="likes", |
| limit=limit |
| )) |
| except Exception as e2: |
| print(f" ⚠️ likes sort도 실패 ({e2}), 기본 list로 fallback") |
| spaces = list(api.list_spaces(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 if hasattr(space, 'author') and space.author else (space.id.split('/')[0] if '/' in space.id else 'unknown'), |
| '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 |
| } |
| |
| fb = self._fallback_space_analysis(space_info) |
| space_info['simple_explanation'] = fb['simple_explanation'] |
| space_info['tech_stack'] = fb['tech_stack'] |
| space_info['description'] = space_info['title'] |
| spaces_list.append(space_info) |
| except Exception as e: |
| print(f" ⚠️ 스페이스 {idx} 기본정보 오류: {e}") |
| continue |
|
|
| print(f"✅ PHASE 1 완료: {len(spaces_list)}개 기본정보 확보") |
|
|
| if spaces_list: |
| try: |
| save_spaces_to_db(spaces_list) |
| print(f"💾 DB 저장 완료 (PHASE 1)") |
| except Exception as e: |
| print(f" ⚠️ DB 저장 오류 (PHASE 1): {e}") |
| except Exception as e: |
| print(f"❌ HF API 스페이스 fetch 오류: {e}") |
| print("💾 DB에서 이전 데이터 로드 시도...\n") |
| return load_spaces_from_db() |
|
|
| |
| if not getattr(self.llm_analyzer, 'api_available', False): |
| print(f"ℹ️ LLM API 미활성 → PHASE 2 건너뜀, fallback 설명만 사용\n") |
| return spaces_list |
|
|
| print(f"🔍 PHASE 2: LLM 분석 시도 (best-effort)") |
| enriched = 0 |
| for idx, s in enumerate(spaces_list, 1): |
| try: |
| llm = self.llm_analyzer.analyze_space(s['name'], s['space_id'], s['title']) |
| if llm and llm.get('simple_explanation') and len(llm['simple_explanation'].strip()) > 10: |
| s['simple_explanation'] = llm['simple_explanation'] |
| if llm.get('tech_stack'): |
| s['tech_stack'] = llm['tech_stack'] |
| enriched += 1 |
| if idx % 5 == 0: |
| print(f" ✓ {idx}/{len(spaces_list)} 처리 ({enriched} 보강 완료)") |
| except Exception as e: |
| continue |
| print(f"✅ PHASE 2 완료: {enriched}/{len(spaces_list)} LLM 보강\n") |
|
|
| try: |
| save_spaces_to_db(spaces_list) |
| except Exception as e: |
| print(f" ⚠️ DB 최종 저장 오류: {e}") |
|
|
| return spaces_list |
|
|
| 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 len(analyzed_models) < 5: |
| print(f"🤗 DB 모델 {len(analyzed_models)}개 (부족) → 새로 수집") |
| fresh_models = self.fetch_huggingface_models(30) |
| if fresh_models: |
| analyzed_models = fresh_models |
| else: |
| print(f"✅ DB에서 {len(analyzed_models)}개 모델 로드\n") |
|
|
| |
| analyzed_spaces = load_spaces_from_db() |
| if len(analyzed_spaces) < 5: |
| print(f"🚀 DB 스페이스 {len(analyzed_spaces)}개 (부족) → 새로 수집") |
| fresh_spaces = self.fetch_huggingface_spaces(30) |
| if fresh_spaces: |
| analyzed_spaces = fresh_spaces |
| 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시간 이내) |
| • 📰 친절한 AI 전문가 톤 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) |