JUNGU commited on
Commit
73b711a
Β·
verified Β·
1 Parent(s): 83ac6e8

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +1118 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,1120 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
1
  import streamlit as st
2
+ import pandas as pd
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+ import re
6
+ import time
7
+ import nltk
8
+ from nltk.tokenize import word_tokenize
9
+ from nltk.corpus import stopwords
10
+ from collections import Counter
11
+ import json
12
+ import os
13
+ from datetime import datetime, timedelta
14
+ import openai
15
+ from dotenv import load_dotenv
16
+ import traceback
17
+ import plotly.graph_objects as go
18
+ import schedule
19
+ import threading
20
+ import matplotlib.pyplot as plt
21
+
22
+ # μ›Œλ“œν΄λΌμš°λ“œ μΆ”κ°€
23
+ try:
24
+ from wordcloud import WordCloud
25
+ except ImportError:
26
+ st.error("wordcloud νŒ¨ν‚€μ§€λ₯Ό μ„€μΉ˜ν•΄μ£Όμ„Έμš”: pip install wordcloud")
27
+ WordCloud = None
28
+
29
+ # μŠ€μΌ€μ€„λŸ¬ μƒνƒœ 클래슀 μΆ”κ°€
30
+ class SchedulerState:
31
+ def __init__(self):
32
+ self.is_running = False
33
+ self.thread = None
34
+ self.last_run = None
35
+ self.next_run = None
36
+ self.scheduled_jobs = []
37
+ self.scheduled_results = []
38
+
39
+ # μ „μ—­ μŠ€μΌ€μ€„λŸ¬ μƒνƒœ 객체 생성 (μŠ€λ ˆλ“œ μ•ˆμ—μ„œ μ‚¬μš©)
40
+ global_scheduler_state = SchedulerState()
41
+
42
+ # API ν‚€ 관리λ₯Ό μœ„ν•œ μ„Έμ…˜ μƒνƒœ μ΄ˆκΈ°ν™”
43
+ if 'openai_api_key' not in st.session_state:
44
+ st.session_state.openai_api_key = None
45
+
46
+ # ν™˜κ²½ λ³€μˆ˜μ—μ„œ API ν‚€ λ‘œλ“œ μ‹œλ„
47
+ load_dotenv()
48
+ if os.getenv('OPENAI_API_KEY'):
49
+ st.session_state.openai_api_key = os.getenv('OPENAI_API_KEY')
50
+ elif 'OPENAI_API_KEY' in st.secrets:
51
+ st.session_state.openai_api_key = st.secrets['OPENAI_API_KEY']
52
+
53
+ # ν•„μš”ν•œ NLTK 데이터 λ‹€μš΄λ‘œλ“œ
54
+ try:
55
+ nltk.data.find('tokenizers/punkt')
56
+ except LookupError:
57
+ nltk.download('punkt')
58
+
59
+ try:
60
+ nltk.data.find('tokenizers/punkt_tab')
61
+ except LookupError:
62
+ nltk.download('punkt_tab')
63
+
64
+ try:
65
+ nltk.data.find('corpora/stopwords')
66
+ except LookupError:
67
+ nltk.download('stopwords')
68
+
69
+ # OpenAI API ν‚€ μ„€μ • (μ‹€μ œ μ‚¬μš© μ‹œ ν™˜κ²½ λ³€μˆ˜λ‚˜ Streamlit secretsμ—μ„œ κ°€μ Έμ˜€λŠ” 것이 μ’‹μŠ΅λ‹ˆλ‹€)
70
+ if 'OPENAI_API_KEY' in os.environ:
71
+ openai.api_key = os.environ['OPENAI_API_KEY']
72
+ elif 'OPENAI_API_KEY' in st.secrets:
73
+ openai.api_key = st.secrets['OPENAI_API_KEY']
74
+ elif os.getenv('OPENAI_API_KEY'):
75
+ openai.api_key = os.getenv('OPENAI_API_KEY')
76
+
77
+ # νŽ˜μ΄μ§€ μ„€μ •
78
+ st.set_page_config(page_title="λ‰΄μŠ€ 기사 도ꡬ", page_icon="πŸ“°", layout="wide")
79
+
80
+ # μ‚¬μ΄λ“œλ°” 메뉴 μ„€μ •
81
+ st.sidebar.title("λ‰΄μŠ€ 기사 도ꡬ")
82
+ menu = st.sidebar.radio(
83
+ "메뉴 선택",
84
+ ["λ‰΄μŠ€ 기사 크둀링", "기사 λΆ„μ„ν•˜κΈ°", "μƒˆ 기사 μƒμ„±ν•˜κΈ°", "λ‰΄μŠ€ 기사 μ˜ˆμ•½ν•˜κΈ°"]
85
+ )
86
+
87
+ # μ €μž₯된 기사λ₯Ό λΆˆλŸ¬μ˜€λŠ” ν•¨μˆ˜
88
+ def load_saved_articles():
89
+ if os.path.exists('saved_articles/articles.json'):
90
+ with open('saved_articles/articles.json', 'r', encoding='utf-8') as f:
91
+ return json.load(f)
92
+ return []
93
+
94
+ # 기사λ₯Ό μ €μž₯ν•˜λŠ” ν•¨μˆ˜
95
+ def save_articles(articles):
96
+ os.makedirs('saved_articles', exist_ok=True)
97
+ with open('saved_articles/articles.json', 'w', encoding='utf-8') as f:
98
+ json.dump(articles, f, ensure_ascii=False, indent=2)
99
+
100
+ @st.cache_data
101
+ def crawl_naver_news(keyword, num_articles=5):
102
+ """
103
+ 넀이버 λ‰΄μŠ€ 기사λ₯Ό μˆ˜μ§‘ν•˜λŠ” ν•¨μˆ˜
104
+ """
105
+ url = f"https://search.naver.com/search.naver?where=news&query={keyword}"
106
+ results = []
107
+
108
+ try:
109
+ # νŽ˜μ΄μ§€ μš”μ²­
110
+ response = requests.get(url)
111
+ soup = BeautifulSoup(response.text, 'html.parser')
112
+
113
+ # λ‰΄μŠ€ μ•„μ΄ν…œ μ°ΎκΈ°
114
+ news_items = soup.select('div.sds-comps-base-layout.sds-comps-full-layout')
115
+
116
+ # 각 λ‰΄μŠ€ μ•„μ΄ν…œμ—μ„œ 정보 μΆ”μΆœ
117
+ for i, item in enumerate(news_items):
118
+ if i >= num_articles:
119
+ break
120
+
121
+ try:
122
+ # 제λͺ©κ³Ό 링크 μΆ”μΆœ
123
+ title_element = item.select_one('a.X0fMYp2dHd0TCUS2hjww span')
124
+ if not title_element:
125
+ continue
126
+
127
+ title = title_element.text.strip()
128
+ link_element = item.select_one('a.X0fMYp2dHd0TCUS2hjww')
129
+ link = link_element['href'] if link_element else ""
130
+
131
+ # 언둠사 μΆ”μΆœ
132
+ press_element = item.select_one('div.sds-comps-profile-info-title span.sds-comps-text-type-body2')
133
+ source = press_element.text.strip() if press_element else "μ•Œ 수 μ—†μŒ"
134
+
135
+ # λ‚ μ§œ μΆ”μΆœ
136
+ date_element = item.select_one('span.r0VOr')
137
+ date = date_element.text.strip() if date_element else "μ•Œ 수 μ—†μŒ"
138
+
139
+ # 미리보기 λ‚΄μš© μΆ”μΆœ
140
+ desc_element = item.select_one('a.X0fMYp2dHd0TCUS2hjww.IaKmSOGPdofdPwPE6cyU > span')
141
+ description = desc_element.text.strip() if desc_element else "λ‚΄μš© μ—†μŒ"
142
+
143
+ results.append({
144
+ 'title': title,
145
+ 'link': link,
146
+ 'description': description,
147
+ 'source': source,
148
+ 'date': date,
149
+ 'content': "" # λ‚˜μ€‘μ— 원문 λ‚΄μš©μ„ μ €μž₯ν•  ν•„λ“œ
150
+ })
151
+
152
+ except Exception as e:
153
+ st.error(f"기사 정보 μΆ”μΆœ 쀑 였λ₯˜ λ°œμƒ: {str(e)}")
154
+ continue
155
+
156
+ except Exception as e:
157
+ st.error(f"νŽ˜μ΄μ§€ μš”μ²­ 쀑 였λ₯˜ λ°œμƒ: {str(e)}")
158
+
159
+ return results
160
+
161
+ # 기사 원문 κ°€μ Έμ˜€κΈ°
162
+ def get_article_content(url):
163
+ try:
164
+ response = requests.get(url, timeout=5)
165
+ soup = BeautifulSoup(response.text, 'html.parser')
166
+
167
+ # 넀이버 λ‰΄μŠ€ λ³Έλ¬Έ μ°ΎκΈ°
168
+ content = soup.select_one('#dic_area')
169
+ if content:
170
+ text = content.text.strip()
171
+ text = re.sub(r'\s+', ' ', text) # μ—¬λŸ¬ 곡백 제거
172
+ return text
173
+
174
+ # λ‹€λ₯Έ λ‰΄μŠ€ μ‚¬μ΄νŠΈ λ³Έλ¬Έ μ°ΎκΈ° (μ—¬λŸ¬ μ‚¬μ΄νŠΈ λŒ€μ‘ ν•„μš”)
175
+ content = soup.select_one('.article_body, .article-body, .article-content, .news-content-inner')
176
+ if content:
177
+ text = content.text.strip()
178
+ text = re.sub(r'\s+', ' ', text)
179
+ return text
180
+
181
+ return "본문을 κ°€μ Έμ˜¬ 수 μ—†μŠ΅λ‹ˆλ‹€."
182
+ except Exception as e:
183
+ return f"였λ₯˜ λ°œμƒ: {str(e)}"
184
+
185
+ # NLTKλ₯Ό μ΄μš©ν•œ ν‚€μ›Œλ“œ 뢄석
186
+ def analyze_keywords(text, top_n=10):
187
+ # ν•œκ΅­μ–΄ λΆˆμš©μ–΄ λͺ©λ‘ (직접 μ •μ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€)
188
+ korean_stopwords = ['이', 'κ·Έ', 'μ €', '것', '및', 'λ“±', 'λ₯Ό', '을', '에', 'μ—μ„œ', '의', '으둜', '둜']
189
+
190
+ tokens = word_tokenize(text)
191
+ tokens = [word for word in tokens if word.isalnum() and len(word) > 1 and word not in korean_stopwords]
192
+
193
+ word_count = Counter(tokens)
194
+ top_keywords = word_count.most_common(top_n)
195
+
196
+ return top_keywords
197
+
198
+ #μ›Œλ“œ ν΄λΌμš°λ“œμš© 뢄석
199
+ def extract_keywords_for_wordcloud(text, top_n=50):
200
+ if not text or len(text.strip()) < 10:
201
+ return {}
202
+
203
+ try:
204
+ try:
205
+ tokens = word_tokenize(text.lower())
206
+ except Exception as e:
207
+ st.warning(f"{str(e)} 였λ₯˜λ°œμƒ")
208
+ tokens = text.lower().split()
209
+
210
+ stop_words = set()
211
+ try:
212
+ stop_words = set(stopwords.words('english'))
213
+ except Exception:
214
+ pass
215
+
216
+ korea_stop_words = {
217
+ '및', 'λ“±', 'λ₯Ό', '이', '의', 'κ°€', '에', 'λŠ”', '으둜', 'μ—μ„œ', 'κ·Έ', '또', 'λ˜λŠ”', 'ν•˜λŠ”', 'ν• ', 'ν•˜κ³ ',
218
+ 'μžˆλ‹€', '이닀', 'μœ„ν•΄', '것이닀', '것은', 'λŒ€ν•œ', 'λ•Œλ¬Έ', '그리고', 'ν•˜μ§€λ§Œ', 'κ·ΈλŸ¬λ‚˜', 'κ·Έλž˜μ„œ',
219
+ 'μž…λ‹ˆλ‹€', 'ν•©λ‹ˆλ‹€', 'μŠ΅λ‹ˆλ‹€', 'μš”', 'μ£ ', 'κ³ ', 'κ³Ό', '와', '도', '은', '수', '것', 'λ“€', '제', 'μ €',
220
+ 'λ…„', 'μ›”', '일', 'μ‹œ', 'λΆ„', '초', 'μ§€λ‚œ', 'μ˜¬ν•΄', 'λ‚΄λ…„', '졜근', 'ν˜„μž¬', '였늘', '내일', 'μ–΄μ œ',
221
+ 'μ˜€μ „', 'μ˜€ν›„', 'λΆ€ν„°', 'κΉŒμ§€', 'μ—κ²Œ', 'κ»˜μ„œ', '이라고', '라고', 'ν•˜λ©°', 'ν•˜λ©΄μ„œ', '따라', '톡해',
222
+ 'κ΄€λ ¨', 'ν•œνŽΈ', '특히', 'κ°€μž₯', '맀우', '더', '덜', '많이', '쑰금', '항상', '자주', '가끔', '거의',
223
+ 'μ „ν˜€', 'λ°”λ‘œ', '정말', 'λ§Œμ•½', 'λΉ„λ‘―ν•œ', '등을', '등이', 'λ“±μ˜', 'λ“±κ³Ό', '등도', '등에', 'λ“±μ—μ„œ',
224
+ '기자', 'λ‰΄μŠ€', '사진', 'μ—°ν•©λ‰΄μŠ€', 'λ‰΄μ‹œμŠ€', '제곡', '무단', 'μ „μž¬', '재배포', 'κΈˆμ§€', '액컀', '멘트',
225
+ '일보', '데일리', '경제', 'μ‚¬νšŒ', 'μ •μΉ˜', '세계', 'κ³Όν•™', '아이티', 'λ‹·μ»΄', '씨넷', 'λΈ”λ‘œν„°', 'μ „μžμ‹ λ¬Έ'
226
+ }
227
+ stop_words.update(korea_stop_words)
228
+
229
+ # 1κΈ€μž 이상이고 λΆˆμš©μ–΄κ°€ μ•„λ‹Œ ν† ν°λ§Œ 필터링
230
+ filtered_tokens = [word for word in tokens if len(word) > 1 and word not in stop_words]
231
+
232
+ # 단어 λΉˆλ„ 계산
233
+ word_freq = {}
234
+ for word in filtered_tokens:
235
+ if word.isalnum(): # μ•ŒνŒŒλ²³κ³Ό 숫자만 ν¬ν•¨λœ λ‹¨μ–΄λ§Œ ν—ˆμš©
236
+ word_freq[word] = word_freq.get(word, 0) + 1
237
+
238
+ # λΉˆλ„μˆœμœΌλ‘œ μ •λ ¬ν•˜μ—¬ μƒμœ„ n개 λ°˜ν™˜
239
+ sorted_words = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
240
+
241
+ if not sorted_words:
242
+ return {"data": 1, "analysis": 1, "news": 1}
243
+
244
+ return dict(sorted_words[:top_n])
245
+
246
+ except Exception as e:
247
+ st.error(f"였λ₯˜λ°œμƒ {str(e)}")
248
+ return {"data": 1, "analysis": 1, "news": 1}
249
+
250
+
251
+ # μ›Œλ“œ ν΄λΌμš°λ“œ 생성 ν•¨μˆ˜
252
+
253
+ def generate_wordcloud(keywords_dict):
254
+ if not WordCloud:
255
+ st.warning("μ›Œλ“œν΄λΌμš°λ“œ μ„€μΉ˜μ•ˆλ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
256
+ return None
257
+ try:
258
+ wc= WordCloud(
259
+ width=800,
260
+ height=400,
261
+ background_color = 'white',
262
+ colormap = 'viridis',
263
+ max_font_size=150,
264
+ random_state=42
265
+ ).generate_from_frequencies(keywords_dict)
266
+
267
+ try:
268
+ possible_font_paths=["NanumGothic.ttf", "이름"]
269
+
270
+ font_path = None
271
+ for path in possible_font_paths:
272
+ if os.path.exists(path):
273
+ font_path = path
274
+ break
275
+
276
+ if font_path:
277
+ wc= WordCloud(
278
+ font_path=font_path,
279
+ width=800,
280
+ height=400,
281
+ background_color = 'white',
282
+ colormap = 'viridis',
283
+ max_font_size=150,
284
+ random_state=42
285
+ ).generate_from_frequencies(keywords_dict)
286
+ except Exception as e:
287
+ print(f"였λ₯˜λ°œμƒ {str(e)}")
288
+
289
+ return wc
290
+
291
+ except Exception as e:
292
+ st.error(f"였λ₯˜λ°œμƒ {str(e)}")
293
+ return None
294
+
295
+ # λ‰΄μŠ€ 뢄석 ν•¨μˆ˜
296
+ def analyze_news_content(news_df):
297
+ if news_df.empty:
298
+ return "데이터가 μ—†μŠ΅λ‹ˆλ‹€"
299
+
300
+ results = {}
301
+ #μΉ΄ν…Œκ³ λ¦¬λ³„
302
+ if 'source' in news_df.columns:
303
+ results['source_counts'] = news_df['source'].value_counts().to_dict()
304
+ #μΉ΄ν…Œκ³ λ¦¬λ³„
305
+ if 'date' in news_df.columns:
306
+ results['date_counts'] = news_df['date'].value_counts().to_dict()
307
+
308
+ #ν‚€μ›Œλ“œλΆ„μ„
309
+ all_text = " ".join(news_df['title'].fillna('') + " " + news_df['content'].fillna(''))
310
+
311
+ if len(all_text.strip()) > 0:
312
+ results['top_keywords_for_wordcloud']= extract_keywords_for_wordcloud(all_text, top_n=50)
313
+ results['top_keywords'] = analyze_keywords(all_text)
314
+ else:
315
+ results['top_keywords_for_wordcloud']={}
316
+ results['top_keywords'] = []
317
+ return results
318
+
319
+ # OpenAI APIλ₯Ό μ΄μš©ν•œ μƒˆ 기사 생성
320
+ def generate_article(original_content, prompt_text):
321
+ try:
322
+ response = openai.chat.completions.create(
323
+ model="gpt-4.1-mini",
324
+ messages=[
325
+ {"role": "system", "content": "당신은 전문적인 λ‰΄μŠ€ κΈ°μžμž…λ‹ˆλ‹€. μ£Όμ–΄μ§„ λ‚΄μš©μ„ λ°”νƒ•μœΌλ‘œ μƒˆλ‘œμš΄ 기사λ₯Ό μž‘μ„±ν•΄μ£Όμ„Έμš”."},
326
+ {"role": "user", "content": f"λ‹€μŒ λ‚΄μš©μ„ λ°”νƒ•μœΌλ‘œ {prompt_text}\n\n{original_content[:1000]}"}
327
+ ],
328
+ max_tokens=2000
329
+ )
330
+ return response.choices[0].message.content
331
+ except Exception as e:
332
+ return f"기사 생성 였λ₯˜: {str(e)}"
333
+
334
+ # OpenAI APIλ₯Ό μ΄μš©ν•œ 이미지 생성
335
+ def generate_image(prompt):
336
+ try:
337
+ response = openai.images.generate(
338
+ model="gpt-image-1",
339
+ prompt=prompt
340
+ )
341
+ image_base64=response.data[0].b64_json
342
+ return f"data:image/png;base64,{image_base64}"
343
+ except Exception as e:
344
+ return f"이미지 생성 였λ₯˜: {str(e)}"
345
+
346
+ # μŠ€μΌ€μ€„λŸ¬ κ΄€λ ¨ ν•¨μˆ˜λ“€
347
+ def get_next_run_time(hour, minute):
348
+ now = datetime.now()
349
+ next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
350
+ if next_run <= now:
351
+ next_run += timedelta(days=1)
352
+ return next_run
353
+
354
+ def run_scheduled_task():
355
+ try:
356
+ while global_scheduler_state.is_running:
357
+ schedule.run_pending()
358
+ time.sleep(1)
359
+ except Exception as e:
360
+ print(f"μŠ€μΌ€μ€„λŸ¬ μ—λŸ¬ λ°œμƒ: {e}")
361
+ traceback.print_exc()
362
+
363
+ def perform_news_task(task_type, keyword, num_articles, file_prefix):
364
+ try:
365
+ articles = crawl_naver_news(keyword, num_articles)
366
+
367
+ # 기사 λ‚΄μš© κ°€μ Έμ˜€κΈ°
368
+ for article in articles:
369
+ article['content'] = get_article_content(article['link'])
370
+ time.sleep(0.5) # μ„œλ²„ λΆ€ν•˜ λ°©μ§€
371
+
372
+ # κ²°κ³Ό μ €μž₯
373
+ os.makedirs('scheduled_news', exist_ok=True)
374
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
375
+ filename = f"scheduled_news/{file_prefix}_{task_type}_{timestamp}.json"
376
+
377
+ with open(filename, 'w', encoding='utf-8') as f:
378
+ json.dump(articles, f, ensure_ascii=False, indent=2)
379
+
380
+ global_scheduler_state.last_run = datetime.now()
381
+ print(f"{datetime.now()} - {task_type} λ‰΄μŠ€ 기사 μˆ˜μ§‘ μ™„λ£Œ: {keyword}")
382
+
383
+ # μ „μ—­ μƒνƒœμ— μˆ˜μ§‘ κ²°κ³Όλ₯Ό μ €μž₯ (UI μ—…λ°μ΄νŠΈμš©)
384
+ result_item = {
385
+ 'task_type': task_type,
386
+ 'keyword': keyword,
387
+ 'timestamp': timestamp,
388
+ 'num_articles': len(articles),
389
+ 'filename': filename
390
+ }
391
+ global_scheduler_state.scheduled_results.append(result_item)
392
+
393
+ except Exception as e:
394
+ print(f"μž‘μ—… μ‹€ν–‰ 쀑 였λ₯˜ λ°œμƒ: {e}")
395
+ traceback.print_exc()
396
+
397
+ def start_scheduler(daily_tasks, interval_tasks):
398
+ if not global_scheduler_state.is_running:
399
+ schedule.clear()
400
+ global_scheduler_state.scheduled_jobs = []
401
+
402
+ # 일별 νƒœμŠ€ν¬ 등둝
403
+ for task in daily_tasks:
404
+ hour = task['hour']
405
+ minute = task['minute']
406
+ keyword = task['keyword']
407
+ num_articles = task['num_articles']
408
+
409
+ job_id = f"daily_{keyword}_{hour}_{minute}"
410
+ schedule.every().day.at(f"{hour:02d}:{minute:02d}").do(
411
+ perform_news_task, "daily", keyword, num_articles, job_id
412
+ ).tag(job_id)
413
+
414
+ global_scheduler_state.scheduled_jobs.append({
415
+ 'id': job_id,
416
+ 'type': 'daily',
417
+ 'time': f"{hour:02d}:{minute:02d}",
418
+ 'keyword': keyword,
419
+ 'num_articles': num_articles
420
+ })
421
+
422
+ # μ‹œκ°„ 간격 νƒœμŠ€ν¬ 등둝
423
+ for task in interval_tasks:
424
+ interval_minutes = task['interval_minutes']
425
+ keyword = task['keyword']
426
+ num_articles = task['num_articles']
427
+ run_immediately = task['run_immediately']
428
+
429
+ job_id = f"interval_{keyword}_{interval_minutes}"
430
+
431
+ if run_immediately:
432
+ # μ¦‰μ‹œ μ‹€ν–‰
433
+ perform_news_task("interval", keyword, num_articles, job_id)
434
+
435
+ # λΆ„ κ°„κ²©μœΌλ‘œ μ˜ˆμ•½
436
+ schedule.every(interval_minutes).minutes.do(
437
+ perform_news_task, "interval", keyword, num_articles, job_id
438
+ ).tag(job_id)
439
+
440
+ global_scheduler_state.scheduled_jobs.append({
441
+ 'id': job_id,
442
+ 'type': 'interval',
443
+ 'interval': f"{interval_minutes}λΆ„λ§ˆλ‹€",
444
+ 'keyword': keyword,
445
+ 'num_articles': num_articles,
446
+ 'run_immediately': run_immediately
447
+ })
448
+
449
+ # λ‹€μŒ μ‹€ν–‰ μ‹œκ°„ 계산
450
+ next_run = schedule.next_run()
451
+ if next_run:
452
+ global_scheduler_state.next_run = next_run
453
+
454
+ # μŠ€μΌ€μ€„λŸ¬ μ“°λ ˆλ“œ μ‹œμž‘
455
+ global_scheduler_state.is_running = True
456
+ global_scheduler_state.thread = threading.Thread(
457
+ target=run_scheduled_task, daemon=True
458
+ )
459
+ global_scheduler_state.thread.start()
460
+
461
+ # μƒνƒœλ₯Ό μ„Έμ…˜ μƒνƒœλ‘œλ„ 볡사 (UI ν‘œμ‹œμš©)
462
+ if 'scheduler_status' not in st.session_state:
463
+ st.session_state.scheduler_status = {}
464
+
465
+ st.session_state.scheduler_status = {
466
+ 'is_running': global_scheduler_state.is_running,
467
+ 'last_run': global_scheduler_state.last_run,
468
+ 'next_run': global_scheduler_state.next_run,
469
+ 'jobs_count': len(global_scheduler_state.scheduled_jobs)
470
+ }
471
+
472
+ def stop_scheduler():
473
+ if global_scheduler_state.is_running:
474
+ global_scheduler_state.is_running = False
475
+ schedule.clear()
476
+ if global_scheduler_state.thread:
477
+ global_scheduler_state.thread.join(timeout=1)
478
+ global_scheduler_state.next_run = None
479
+ global_scheduler_state.scheduled_jobs = []
480
+
481
+ # UI μƒνƒœ μ—…λ°μ΄νŠΈ
482
+ if 'scheduler_status' in st.session_state:
483
+ st.session_state.scheduler_status['is_running'] = False
484
+
485
+ # 메뉴에 λ”°λ₯Έ ν™”λ©΄ ν‘œμ‹œ
486
+ if menu == "λ‰΄μŠ€ 기사 크둀링":
487
+ st.header("λ‰΄μŠ€ 기사 크둀링")
488
+
489
+ keyword = st.text_input("검색어 μž…λ ₯", "인곡지λŠ₯")
490
+ num_articles = st.slider("κ°€μ Έμ˜¬ 기사 수", min_value=1, max_value=20, value=5)
491
+
492
+ if st.button("기사 κ°€μ Έμ˜€κΈ°"):
493
+ with st.spinner("기사λ₯Ό μˆ˜μ§‘ μ€‘μž…λ‹ˆλ‹€..."):
494
+ articles = crawl_naver_news(keyword, num_articles)
495
+
496
+ # 기사 λ‚΄μš© κ°€μ Έμ˜€κΈ°
497
+ for i, article in enumerate(articles):
498
+ st.progress((i + 1) / len(articles))
499
+ article['content'] = get_article_content(article['link'])
500
+ time.sleep(0.5) # μ„œλ²„ λΆ€ν•˜ λ°©μ§€
501
+
502
+ # κ²°κ³Ό μ €μž₯ 및 ν‘œμ‹œ
503
+ save_articles(articles)
504
+ st.success(f"{len(articles)}개의 기사λ₯Ό μˆ˜μ§‘ν–ˆμŠ΅λ‹ˆλ‹€!")
505
+
506
+ # μˆ˜μ§‘ν•œ 기사 ν‘œμ‹œ
507
+ for article in articles:
508
+ with st.expander(f"{article['title']} - {article['source']}"):
509
+ st.write(f"**좜처:** {article['source']}")
510
+ st.write(f"**λ‚ μ§œ:** {article['date']}")
511
+ st.write(f"**μš”μ•½:** {article['description']}")
512
+ st.write(f"**링크:** {article['link']}")
513
+ st.write("**본문 미리보기:**")
514
+ st.write(article['content'][:300] + "...")
515
+
516
+ elif menu == "기사 λΆ„μ„ν•˜κΈ°":
517
+ st.header("기사 λΆ„μ„ν•˜κΈ°")
518
+
519
+ articles = load_saved_articles()
520
+ if not articles:
521
+ st.warning("μ €μž₯된 기사가 μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € 'λ‰΄μŠ€ 기사 크둀링' λ©”λ‰΄μ—μ„œ 기사λ₯Ό μˆ˜μ§‘ν•΄μ£Όμ„Έμš”.")
522
+ else:
523
+ # 기사 선택
524
+ titles = [article['title'] for article in articles]
525
+ selected_title = st.selectbox("뢄석할 기사 선택", titles)
526
+
527
+ selected_article = next((a for a in articles if a['title'] == selected_title), None)
528
+
529
+ if selected_article:
530
+ st.write(f"**제λͺ©:** {selected_article['title']}")
531
+ st.write(f"**좜처:** {selected_article['source']}")
532
+
533
+ # λ³Έλ¬Έ ν‘œμ‹œ
534
+ with st.expander("기사 λ³Έλ¬Έ 보기"):
535
+ st.write(selected_article['content'])
536
+
537
+ # 뢄석 방법 선택
538
+ analysis_type = st.radio(
539
+ "뢄석 방법",
540
+ ["ν‚€μ›Œλ“œ 뢄석", "감정 뢄석", "ν…μŠ€νŠΈ 톡계"]
541
+ )
542
+
543
+ if analysis_type == "ν‚€μ›Œλ“œ 뢄석":
544
+ if st.button("ν‚€μ›Œλ“œ λΆ„μ„ν•˜κΈ°"):
545
+ with st.spinner("ν‚€μ›Œλ“œλ₯Ό 뢄석 μ€‘μž…λ‹ˆλ‹€..."):
546
+ keyword_tab1, keyword_tab2 = st.tabs(["ν‚€μ›Œλ“œ λΉˆλ„", "μ›Œλ“œν΄λΌμš°λ“œ"])
547
+
548
+ with keyword_tab1:
549
+
550
+ keywords = analyze_keywords(selected_article['content'])
551
+
552
+ # μ‹œκ°ν™”
553
+ df = pd.DataFrame(keywords, columns=['단어', 'λΉˆλ„μˆ˜'])
554
+ st.bar_chart(df.set_index('단어'))
555
+
556
+ st.write("**μ£Όμš” ν‚€μ›Œλ“œ:**")
557
+ for word, count in keywords:
558
+ st.write(f"- {word}: {count}회")
559
+ with keyword_tab2:
560
+ keyword_dict = extract_keywords_for_wordcloud(selected_article['content'])
561
+ wc = generate_wordcloud(keyword_dict)
562
+
563
+ if wc:
564
+ fig, ax = plt.subplots(figsize=(10, 5))
565
+ ax.imshow(wc, interpolation='bilinear')
566
+ ax.axis('off')
567
+ st.pyplot(fig)
568
+
569
+ # ν‚€μ›Œλ“œ μƒμœ„ 20개 ν‘œμ‹œ
570
+ st.write("**μƒμœ„ 20개 ν‚€μ›Œλ“œ:**")
571
+ top_keywords = sorted(keyword_dict.items(), key=lambda x: x[1], reverse=True)[:20]
572
+ keyword_df = pd.DataFrame(top_keywords, columns=['ν‚€μ›Œλ“œ', 'λΉˆλ„'])
573
+ st.dataframe(keyword_df)
574
+ else:
575
+ st.error("μ›Œλ“œν΄λΌμš°λ“œλ₯Ό 생성할 수 μ—†μŠ΅λ‹ˆλ‹€.")
576
+
577
+ elif analysis_type == "ν…μŠ€νŠΈ 톡계":
578
+ if st.button("ν…μŠ€νŠΈ 톡계 뢄석"):
579
+ content = selected_article['content']
580
+
581
+ # ν…μŠ€νŠΈ 톡계 계산
582
+ word_count = len(re.findall(r'\b\w+\b', content))
583
+ char_count = len(content)
584
+ sentence_count = len(re.split(r'[.!?]+', content))
585
+ avg_word_length = sum(len(word) for word in re.findall(r'\b\w+\b', content)) / word_count if word_count > 0 else 0
586
+ avg_sentence_length = word_count / sentence_count if sentence_count > 0 else 0
587
+
588
+ # 톡계 ν‘œμ‹œ
589
+ st.subheader("ν…μŠ€νŠΈ 톡계")
590
+ col1, col2, col3 = st.columns(3)
591
+ with col1:
592
+ st.metric("단어 수", f"{word_count:,}")
593
+ with col2:
594
+ st.metric("문자 수", f"{char_count:,}")
595
+ with col3:
596
+ st.metric("λ¬Έμž₯ 수", f"{sentence_count:,}")
597
+
598
+ col1, col2 = st.columns(2)
599
+ with col1:
600
+ st.metric("평균 단어 길이", f"{avg_word_length:.1f}자")
601
+ with col2:
602
+ st.metric("평균 λ¬Έμž₯ 길이", f"{avg_sentence_length:.1f}단어")
603
+
604
+ # ν…μŠ€νŠΈ λ³΅μž‘μ„± 점수 (κ°„λ‹¨ν•œ μ˜ˆμ‹œ)
605
+ complexity_score = min(10, (avg_sentence_length / 10) * 5 + (avg_word_length / 5) * 5)
606
+ st.progress(complexity_score / 10)
607
+ st.write(f"ν…μŠ€νŠΈ λ³΅μž‘μ„± 점수: {complexity_score:.1f}/10")
608
+
609
+ # μΆœν˜„ λΉˆλ„ λ§‰λŒ€ κ·Έλž˜ν”„
610
+ st.subheader("ν’ˆμ‚¬λ³„ 뢄포 (ν•œκ΅­μ–΄/μ˜μ–΄ 지원)")
611
+ try:
612
+ # KoNLPy μ„€μΉ˜ 확인
613
+ try:
614
+ from konlpy.tag import Okt
615
+ konlpy_installed = True
616
+ except ImportError:
617
+ konlpy_installed = False
618
+ st.warning("ν•œκ΅­μ–΄ ν˜•νƒœμ†Œ 뢄석을 μœ„ν•΄ KoNLPyλ₯Ό μ„€μΉ˜ν•΄μ£Όμ„Έμš”: pip install konlpy")
619
+
620
+ # μ˜μ–΄ POS tagger μ€€λΉ„
621
+ from nltk import pos_tag
622
+ try:
623
+ nltk.data.find('taggers/averaged_perceptron_tagger')
624
+ except LookupError:
625
+ nltk.download('averaged_perceptron_tagger')
626
+
627
+ # Try using the correct resource name as shown in the error message
628
+ try:
629
+ nltk.data.find('averaged_perceptron_tagger_eng')
630
+ except LookupError:
631
+ nltk.download('averaged_perceptron_tagger_eng')
632
+
633
+ # μ–Έμ–΄ 감지 (κ°„λ‹¨ν•œ 방식)
634
+ is_korean = bool(re.search(r'[κ°€-힣]', content))
635
+
636
+ if is_korean and konlpy_installed:
637
+ # ν•œκ΅­μ–΄ ν˜•νƒœμ†Œ 뢄석
638
+ okt = Okt()
639
+ tagged = okt.pos(content)
640
+
641
+ # ν•œκ΅­μ–΄ ν’ˆμ‚¬ λ§€ν•‘
642
+ pos_dict = {
643
+ 'Noun': 'λͺ…사', 'NNG': 'λͺ…사', 'NNP': '고유λͺ…사',
644
+ 'Verb': '동사', 'VV': '동사', 'VA': 'ν˜•μš©μ‚¬',
645
+ 'Adjective': 'ν˜•μš©μ‚¬',
646
+ 'Adverb': '뢀사',
647
+ 'Josa': '쑰사', 'Punctuation': 'ꡬ두점',
648
+ 'Determiner': 'κ΄€ν˜•μ‚¬', 'Exclamation': '감탄사'
649
+ }
650
+
651
+ pos_counts = {'λͺ…사': 0, '동사': 0, 'ν˜•μš©μ‚¬': 0, '뢀사': 0, '쑰사': 0, 'ꡬ두점': 0, 'κ΄€ν˜•μ‚¬': 0, '감탄사': 0, '기타': 0}
652
+
653
+ for _, pos in tagged:
654
+ if pos in pos_dict:
655
+ pos_counts[pos_dict[pos]] += 1
656
+ elif pos.startswith('N'): # 기타 λͺ…사λ₯˜
657
+ pos_counts['λͺ…사'] += 1
658
+ elif pos.startswith('V'): # 기타 동사λ₯˜
659
+ pos_counts['동사'] += 1
660
+ else:
661
+ pos_counts['기타'] += 1
662
+
663
+ else:
664
+ # μ˜μ–΄ POS νƒœκΉ…
665
+ tokens = word_tokenize(content.lower())
666
+ tagged = pos_tag(tokens)
667
+
668
+ # μ˜μ–΄ ν’ˆμ‚¬ λ§€ν•‘
669
+ pos_dict = {
670
+ 'NN': 'λͺ…사', 'NNS': 'λͺ…사', 'NNP': '고유λͺ…사', 'NNPS': '고유λͺ…사',
671
+ 'VB': '동사', 'VBD': '동사', 'VBG': '동사', 'VBN': '동사', 'VBP': '동사', 'VBZ': '동사',
672
+ 'JJ': 'ν˜•μš©μ‚¬', 'JJR': 'ν˜•μš©μ‚¬', 'JJS': 'ν˜•μš©μ‚¬',
673
+ 'RB': '뢀사', 'RBR': '뢀사', 'RBS': '뢀사'
674
+ }
675
+
676
+ pos_counts = {'λͺ…사': 0, '동사': 0, 'ν˜•μš©μ‚¬': 0, '뢀사': 0, '기타': 0}
677
+
678
+ for _, pos in tagged:
679
+ if pos in pos_dict:
680
+ pos_counts[pos_dict[pos]] += 1
681
+ else:
682
+ pos_counts['기타'] += 1
683
+
684
+ # κ²°κ³Ό μ‹œκ°ν™”
685
+ pos_df = pd.DataFrame({
686
+ 'ν’ˆμ‚¬': list(pos_counts.keys()),
687
+ 'λΉˆλ„': list(pos_counts.values())
688
+ })
689
+
690
+ st.bar_chart(pos_df.set_index('ν’ˆμ‚¬'))
691
+
692
+ if is_korean:
693
+ st.info("ν•œκ΅­μ–΄ ν…μŠ€νŠΈκ°€ κ°μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
694
+ else:
695
+ st.info("μ˜μ–΄ ν…μŠ€νŠΈκ°€ κ°μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
696
+ except Exception as e:
697
+ st.error(f"ν’ˆμ‚¬ 뢄석 쀑 였λ₯˜ λ°œμƒ: {str(e)}")
698
+ st.error(traceback.format_exc())
699
+
700
+ elif analysis_type == "감정 뢄석":
701
+ if st.button("감정 λΆ„μ„ν•˜κΈ°"):
702
+ if st.session_state.openai_api_key:
703
+ with st.spinner("κΈ°μ‚¬μ˜ 감정을 뢄석 μ€‘μž…λ‹ˆλ‹€..."):
704
+ try:
705
+ openai.api_key = st.session_state.openai_api_key
706
+
707
+ # 감정 뢄석 ν”„λ‘¬ν”„νŠΈ μ„€μ •
708
+ response = openai.chat.completions.create(
709
+ model="gpt-4.1-mini",
710
+ messages=[
711
+ {"role": "system", "content": "당신은 ν…μŠ€νŠΈμ˜ 감정과 λ…Όμ‘°λ₯Ό λΆ„μ„ν•˜λŠ” μ „λ¬Έκ°€μž…λ‹ˆλ‹€. λ‹€μŒ λ‰΄μŠ€ κΈ°μ‚¬μ˜ 감정과 λ…Όμ‘°λ₯Ό λΆ„μ„ν•˜κ³ , '긍정적', '뢀정적', '쀑립적' 쀑 ν•˜λ‚˜λ‘œ λΆ„λ₯˜ν•΄ μ£Όμ„Έμš”. λ˜ν•œ κΈ°μ‚¬μ—μ„œ λ“œλŸ¬λ‚˜λŠ” 핡심 감정 ν‚€μ›Œλ“œλ₯Ό 5개 μΆ”μΆœν•˜κ³ , 각 ν‚€μ›Œλ“œλ³„λ‘œ 1-10 μ‚¬μ΄μ˜ 강도 점수λ₯Ό λ§€κ²¨μ£Όμ„Έμš”. JSON ν˜•μ‹μœΌλ‘œ λ‹€μŒκ³Ό 같이 μ‘λ‹΅ν•΄μ£Όμ„Έμš”: {'sentiment': '긍정적/뢀정적/쀑립적', 'reason': '이유 μ„€λͺ…...', 'keywords': [{'word': 'ν‚€μ›Œλ“œ1', 'score': 8}, {'word': 'ν‚€μ›Œλ“œ2', 'score': 7}, ...]}"},
712
+ {"role": "user", "content": f"λ‹€μŒ λ‰΄μŠ€ 기사λ₯Ό 뢄석해 μ£Όμ„Έμš”:\n\n제λͺ©: {selected_article['title']}\n\nλ‚΄μš©: {selected_article['content'][:1500]}"}
713
+ ],
714
+ max_tokens=800,
715
+ response_format={"type": "json_object"}
716
+ )
717
+
718
+ # JSON νŒŒμ‹±
719
+ analysis_result = json.loads(response.choices[0].message.content)
720
+
721
+ # κ²°κ³Ό μ‹œκ°ν™”
722
+ st.subheader("감정 뢄석 κ²°κ³Ό")
723
+
724
+ # 1. 감정 νƒ€μž…μ— λ”°λ₯Έ μ‹œκ°μ  ν‘œν˜„
725
+ sentiment_type = analysis_result.get('sentiment', '쀑립적')
726
+ col1, col2, col3 = st.columns([1, 3, 1])
727
+
728
+ with col2:
729
+ if sentiment_type == "긍정적":
730
+ st.markdown(f"""
731
+ <div style="background-color:#DCEDC8; padding:20px; border-radius:10px; text-align:center;">
732
+ <h1 style="color:#388E3C; font-size:28px;">πŸ˜€ 긍정적 λ…Όμ‘° πŸ˜€</h1>
733
+ <p style="font-size:16px;">감정 강도: λ†’μŒ</p>
734
+ </div>
735
+ """, unsafe_allow_html=True)
736
+ elif sentiment_type == "뢀정적":
737
+ st.markdown(f"""
738
+ <div style="background-color:#FFCDD2; padding:20px; border-radius:10px; text-align:center;">
739
+ <h1 style="color:#D32F2F; font-size:28px;">😞 뢀정적 λ…Όμ‘° 😞</h1>
740
+ <p style="font-size:16px;">감정 강도: λ†’μŒ</p>
741
+ </div>
742
+ """, unsafe_allow_html=True)
743
+ else:
744
+ st.markdown(f"""
745
+ <div style="background-color:#E0E0E0; padding:20px; border-radius:10px; text-align:center;">
746
+ <h1 style="color:#616161; font-size:28px;">😐 쀑립적 λ…Όμ‘° 😐</h1>
747
+ <p style="font-size:16px;">감정 강도: 쀑간</p>
748
+ </div>
749
+ """, unsafe_allow_html=True)
750
+
751
+ # 2. 이유 μ„€λͺ…
752
+ st.markdown("### 뢄석 κ·Όκ±°")
753
+ st.markdown(f"<div style='background-color:#F5F5F5; padding:15px; border-radius:5px;'>{analysis_result.get('reason', '')}</div>", unsafe_allow_html=True)
754
+
755
+ # 3. 감정 ν‚€μ›Œλ“œ μ‹œκ°ν™”
756
+ st.markdown("### 핡심 감정 ν‚€μ›Œλ“œ")
757
+
758
+ # ν‚€μ›Œλ“œ 데이터 μ€€λΉ„
759
+ keywords = analysis_result.get('keywords', [])
760
+ if keywords:
761
+ # λ§‰λŒ€ 차트용 데이터
762
+ keyword_names = [item.get('word', '') for item in keywords]
763
+ keyword_scores = [item.get('score', 0) for item in keywords]
764
+
765
+ # λ ˆμ΄λ” 차트 생성
766
+ fig = go.Figure()
767
+
768
+ # 색상 μ„€μ •
769
+ if sentiment_type == "긍정적":
770
+ fill_color = 'rgba(76, 175, 80, 0.3)' # μ—°ν•œ μ΄ˆλ‘μƒ‰
771
+ line_color = 'rgba(76, 175, 80, 1)' # μ§„ν•œ μ΄ˆλ‘μƒ‰
772
+ elif sentiment_type == "뢀정적":
773
+ fill_color = 'rgba(244, 67, 54, 0.3)' # μ—°ν•œ 빨간색
774
+ line_color = 'rgba(244, 67, 54, 1)' # μ§„ν•œ 빨간색
775
+ else:
776
+ fill_color = 'rgba(158, 158, 158, 0.3)' # μ—°ν•œ νšŒμƒ‰
777
+ line_color = 'rgba(158, 158, 158, 1)' # μ§„ν•œ νšŒμƒ‰
778
+
779
+ # λ ˆμ΄λ” 차트 데이터 μ€€λΉ„ - λ§ˆμ§€λ§‰ 점이 첫 점과 μ—°κ²°λ˜λ„λ‘ 데이터 μΆ”κ°€
780
+ radar_keywords = keyword_names.copy()
781
+ radar_scores = keyword_scores.copy()
782
+
783
+ # λ ˆμ΄λ” 차트 생성
784
+ fig.add_trace(go.Scatterpolar(
785
+ r=radar_scores,
786
+ theta=radar_keywords,
787
+ fill='toself',
788
+ fillcolor=fill_color,
789
+ line=dict(color=line_color, width=2),
790
+ name='감정 ν‚€μ›Œλ“œ'
791
+ ))
792
+
793
+ # λ ˆμ΄λ” 차트 λ ˆμ΄μ•„μ›ƒ μ„€μ •
794
+ fig.update_layout(
795
+ polar=dict(
796
+ radialaxis=dict(
797
+ visible=True,
798
+ range=[0, 10],
799
+ tickmode='linear',
800
+ tick0=0,
801
+ dtick=2
802
+ )
803
+ ),
804
+ showlegend=False,
805
+ title={
806
+ 'text': '감정 ν‚€μ›Œλ“œ λ ˆμ΄λ” 뢄석',
807
+ 'y':0.95,
808
+ 'x':0.5,
809
+ 'xanchor': 'center',
810
+ 'yanchor': 'top'
811
+ },
812
+ height=500,
813
+ width=500,
814
+ margin=dict(l=80, r=80, t=80, b=80)
815
+ )
816
+
817
+ # 차트 쀑앙에 ν‘œμ‹œ
818
+ col1, col2, col3 = st.columns([1, 2, 1])
819
+ with col2:
820
+ st.plotly_chart(fig)
821
+
822
+ # ν‚€μ›Œλ“œ μΉ΄λ“œλ‘œ ν‘œμ‹œ
823
+ st.markdown("#### ν‚€μ›Œλ“œ μ„ΈλΆ€ μ„€λͺ…")
824
+ cols = st.columns(min(len(keywords), 5))
825
+ for i, keyword in enumerate(keywords):
826
+ with cols[i % len(cols)]:
827
+ word = keyword.get('word', '')
828
+ score = keyword.get('score', 0)
829
+
830
+ # μ μˆ˜μ— λ”°λ₯Έ 색상 계산
831
+ r, g, b = 0, 0, 0
832
+ if sentiment_type == "긍정적":
833
+ g = min(200 + score * 5, 255)
834
+ r = max(255 - score * 20, 100)
835
+ elif sentiment_type == "뢀정적":
836
+ r = min(200 + score * 5, 255)
837
+ g = max(255 - score * 20, 100)
838
+ else:
839
+ r = g = b = 128
840
+
841
+ # μΉ΄λ“œ 생성
842
+ st.markdown(f"""
843
+ <div style="background-color:rgba({r},{g},{b},0.2); padding:10px; border-radius:5px; text-align:center; margin:5px;">
844
+ <h3 style="margin:0;">{word}</h3>
845
+ <div style="background-color:#E0E0E0; border-radius:3px; margin-top:5px;">
846
+ <div style="width:{score*10}%; background-color:rgba({r},{g},{b},0.8); height:10px; border-radius:3px;"></div>
847
+ </div>
848
+ <p style="margin:2px; font-size:12px;">강도: {score}/10</p>
849
+ </div>
850
+ """, unsafe_allow_html=True)
851
+
852
+ else:
853
+ st.info("ν‚€μ›Œλ“œλ₯Ό μΆ”μΆœν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.")
854
+
855
+ # 4. μš”μ•½ 톡계
856
+ st.markdown("### μ£Όμš” 톡계")
857
+ col1, col2, col3 = st.columns(3)
858
+ with col1:
859
+ st.metric(label="긍정/λΆ€μ • 점수", value=f"{7 if sentiment_type == '긍정적' else 3 if sentiment_type == '뢀정적' else 5}/10")
860
+ with col2:
861
+ st.metric(label="ν‚€μ›Œλ“œ 수", value=len(keywords))
862
+ with col3:
863
+ avg_score = sum(keyword_scores) / len(keyword_scores) if keyword_scores else 0
864
+ st.metric(label="평균 강도", value=f"{avg_score:.1f}/10")
865
+
866
+ except Exception as e:
867
+ st.error(f"감정 뢄석 였λ₯˜: {str(e)}")
868
+ st.code(traceback.format_exc())
869
+ else:
870
+ st.warning("OpenAI API ν‚€κ°€ μ„€μ •λ˜μ–΄ μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€. μ‚¬μ΄λ“œλ°”μ—μ„œ API ν‚€λ₯Ό μ„€μ •ν•΄μ£Όμ„Έμš”.")
871
+
872
+ elif menu == "μƒˆ 기사 μƒμ„±ν•˜κΈ°":
873
+ st.header("μƒˆ 기사 μƒμ„±ν•˜κΈ°")
874
+
875
+ articles = load_saved_articles()
876
+ if not articles:
877
+ st.warning("μ €μž₯된 기사가 μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € 'λ‰΄μŠ€ 기사 크둀링' λ©”λ‰΄μ—μ„œ 기사λ₯Ό μˆ˜μ§‘ν•΄μ£Όμ„Έμš”.")
878
+ else:
879
+ # 기사 선택
880
+ titles = [article['title'] for article in articles]
881
+ selected_title = st.selectbox("원본 기사 선택", titles)
882
+
883
+ selected_article = next((a for a in articles if a['title'] == selected_title), None)
884
+
885
+ if selected_article:
886
+ st.write(f"**원본 제λͺ©:** {selected_article['title']}")
887
+
888
+ with st.expander("원본 기사 λ‚΄μš©"):
889
+ st.write(selected_article['content'])
890
+
891
+ prompt_text ="""λ‹€μŒ 기사 양식을 λ”°λΌμ„œ λ‹€μ‹œ μž‘μ„±ν•΄μ€˜.
892
+ μ—­ν• : 당신은 μ‹ λ¬Έμ‚¬μ˜ κΈ°μžμž…λ‹ˆλ‹€.
893
+ μž‘μ—…: 졜근 μΌμ–΄λ‚œ 사건에 λŒ€ν•œ λ³΄λ„μžλ£Œλ₯Ό μž‘μ„±ν•΄μ•Ό ν•©λ‹ˆλ‹€. μžλ£ŒλŠ” 사싀을 기반으둜 ν•˜λ©°, 객관적이고 μ •ν™•ν•΄μ•Ό ν•©λ‹ˆλ‹€.
894
+ μ§€μΉ¨:
895
+ 제곡된 정보λ₯Ό λ°”νƒ•μœΌλ‘œ μ‹ λ¬Έ λ³΄λ„μžλ£Œ ν˜•μ‹μ— 맞좰 기사λ₯Ό μž‘μ„±ν•˜μ„Έμš”.
896
+ 기사 제λͺ©μ€ 주제λ₯Ό λͺ…ν™•νžˆ λ°˜μ˜ν•˜κ³  λ…μžμ˜ 관심을 끌 수 μžˆλ„λ‘ μž‘μ„±ν•©λ‹ˆλ‹€.
897
+ 기사 λ‚΄μš©μ€ μ •ν™•ν•˜κ³  κ°„κ²°ν•˜λ©° 섀득λ ₯ μžˆλŠ” λ¬Έμž₯으둜 κ΅¬μ„±ν•©λ‹ˆλ‹€.
898
+ κ΄€λ ¨μžμ˜ 인터뷰λ₯Ό 인용 ν˜•νƒœλ‘œ λ„£μ–΄μ£Όμ„Έμš”.
899
+ μœ„μ˜ 정보와 지침을 μ°Έκ³ ν•˜μ—¬ μ‹ λ¬Έ λ³΄λ„μžλ£Œ ν˜•μ‹μ˜ 기사λ₯Ό μž‘μ„±ν•΄ μ£Όμ„Έμš”"""
900
+
901
+ # 이미지 생성 μ—¬λΆ€ 선택 μ˜΅μ…˜ μΆ”κ°€
902
+ generate_image_too = st.checkbox("기사 생성 ν›„ 이미지도 ν•¨κ»˜ μƒμ„±ν•˜κΈ°", value=True)
903
+
904
+ if st.button("μƒˆ 기사 μƒμ„±ν•˜κΈ°"):
905
+ if st.session_state.openai_api_key:
906
+ openai.api_key = st.session_state.openai_api_key
907
+ with st.spinner("기사λ₯Ό 생성 μ€‘μž…λ‹ˆλ‹€..."):
908
+ new_article = generate_article(selected_article['content'], prompt_text)
909
+
910
+ st.write("**μƒμ„±λœ 기사:**")
911
+ st.write(new_article)
912
+
913
+ # 이미지 μƒμ„±ν•˜κΈ° (μ˜΅μ…˜μ΄ μ„ νƒλœ 경우)
914
+ if generate_image_too:
915
+ with st.spinner("기사 κ΄€λ ¨ 이미지λ₯Ό 생성 μ€‘μž…λ‹ˆλ‹€..."):
916
+ # 이미지 생성 ν”„λ‘¬ν”„νŠΈ μ€€λΉ„
917
+ image_prompt = f"""신문기사 제λͺ© "{selected_article['title']}" 을 보고 이미지λ₯Ό λ§Œλ“€μ–΄μ€˜
918
+ μ΄λ―Έμ§€μ—λŠ” λ‹€μŒ μš”μ†Œκ°€ ν¬ν•¨λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€:
919
+ - 기사λ₯Ό 이해할 수 μžˆλŠ” 도식
920
+ - 기사 λ‚΄μš©κ³Ό κ΄€λ ¨λœ ν…μŠ€νŠΈ
921
+ - μ‹¬ν”Œν•˜κ²Œ 처리
922
+ """
923
+
924
+ # 이미지 생성
925
+ image_url = generate_image(image_prompt)
926
+
927
+ if image_url and not image_url.startswith("이��지 생성 였λ₯˜"):
928
+ st.subheader("μƒμ„±λœ 이미지:")
929
+ st.image(image_url)
930
+ else:
931
+ st.error(image_url)
932
+
933
+ # μƒμ„±λœ 기사 μ €μž₯ μ˜΅μ…˜
934
+ if st.button("μƒμ„±λœ 기사 μ €μž₯"):
935
+ new_article_data = {
936
+ 'title': f"[생성됨] {selected_article['title']}",
937
+ 'source': f"AI 생성 (원본: {selected_article['source']})",
938
+ 'date': datetime.now().strftime("%Y-%m-%d %H:%M"),
939
+ 'description': new_article[:100] + "...",
940
+ 'link': "",
941
+ 'content': new_article
942
+ }
943
+ articles.append(new_article_data)
944
+ save_articles(articles)
945
+ st.success("μƒμ„±λœ 기사가 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€!")
946
+ else:
947
+ st.warning("OpenAI API ν‚€λ₯Ό μ‚¬μ΄λ“œλ°”μ—μ„œ μ„€μ •ν•΄μ£Όμ„Έμš”.")
948
+
949
+
950
+
951
+ elif menu == "λ‰΄μŠ€ 기사 μ˜ˆμ•½ν•˜κΈ°":
952
+ st.header("λ‰΄μŠ€ 기사 μ˜ˆμ•½ν•˜κΈ°")
953
+
954
+ # νƒ­ 생성
955
+ tab1, tab2, tab3 = st.tabs(["일별 μ˜ˆμ•½", "μ‹œκ°„ 간격 μ˜ˆμ•½", "μŠ€μΌ€μ€„λŸ¬ μƒνƒœ"])
956
+
957
+ # 일별 μ˜ˆμ•½ νƒ­
958
+ with tab1:
959
+ st.subheader("맀일 μ •ν•΄μ§„ μ‹œκ°„μ— 기사 μˆ˜μ§‘ν•˜κΈ°")
960
+
961
+ # ν‚€μ›Œλ“œ μž…λ ₯
962
+ daily_keyword = st.text_input("검색 ν‚€μ›Œλ“œ", value="인곡지λŠ₯", key="daily_keyword")
963
+ daily_num_articles = st.slider("μˆ˜μ§‘ν•  기사 수", min_value=1, max_value=20, value=5, key="daily_num_articles")
964
+
965
+ # μ‹œκ°„ μ„€μ •
966
+ daily_col1, daily_col2 = st.columns(2)
967
+ with daily_col1:
968
+ daily_hour = st.selectbox("μ‹œ", range(24), format_func=lambda x: f"{x:02d}μ‹œ", key="daily_hour")
969
+ with daily_col2:
970
+ daily_minute = st.selectbox("λΆ„", range(0, 60, 5), format_func=lambda x: f"{x:02d}λΆ„", key="daily_minute")
971
+
972
+ # 일별 μ˜ˆμ•½ 리슀트
973
+ if 'daily_tasks' not in st.session_state:
974
+ st.session_state.daily_tasks = []
975
+
976
+ if st.button("일별 μ˜ˆμ•½ μΆ”κ°€"):
977
+ st.session_state.daily_tasks.append({
978
+ 'hour': daily_hour,
979
+ 'minute': daily_minute,
980
+ 'keyword': daily_keyword,
981
+ 'num_articles': daily_num_articles
982
+ })
983
+ st.success(f"일별 μ˜ˆμ•½μ΄ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€: 맀일 {daily_hour:02d}:{daily_minute:02d} - '{daily_keyword}'")
984
+
985
+ # μ˜ˆμ•½ λͺ©λ‘ ν‘œμ‹œ
986
+ if st.session_state.daily_tasks:
987
+ st.subheader("일별 μ˜ˆμ•½ λͺ©λ‘")
988
+ for i, task in enumerate(st.session_state.daily_tasks):
989
+ st.write(f"{i+1}. 맀일 {task['hour']:02d}:{task['minute']:02d} - '{task['keyword']}' ({task['num_articles']}개)")
990
+
991
+ if st.button("일별 μ˜ˆμ•½ μ΄ˆκΈ°ν™”"):
992
+ st.session_state.daily_tasks = []
993
+ st.warning("일별 μ˜ˆμ•½μ΄ λͺ¨λ‘ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
994
+
995
+ # μ‹œκ°„ 간격 μ˜ˆμ•½ νƒ­
996
+ with tab2:
997
+ st.subheader("μ‹œκ°„ κ°„κ²©μœΌλ‘œ 기사 μˆ˜μ§‘ν•˜κΈ°")
998
+
999
+ # ν‚€μ›Œλ“œ μž…λ ₯
1000
+ interval_keyword = st.text_input("검색 ν‚€μ›Œλ“œ", value="빅데이터", key="interval_keyword")
1001
+ interval_num_articles = st.slider("μˆ˜μ§‘ν•  기사 수", min_value=1, max_value=20, value=5, key="interval_num_articles")
1002
+
1003
+ # μ‹œκ°„ 간격 μ„€μ •
1004
+ interval_minutes = st.number_input("μ‹€ν–‰ 간격(λΆ„)", min_value=1, max_value=60*24, value=30, key="interval_minutes")
1005
+
1006
+ # μ¦‰μ‹œ μ‹€ν–‰ μ—¬λΆ€
1007
+ run_immediately = st.checkbox("μ¦‰μ‹œ μ‹€ν–‰", value=True, help="μ²΄ν¬ν•˜λ©΄ μŠ€μΌ€μ€„λŸ¬ μ‹œμž‘ μ‹œ μ¦‰μ‹œ μ‹€ν–‰ν•©λ‹ˆλ‹€.")
1008
+
1009
+ # μ‹œκ°„ 간격 μ˜ˆμ•½ 리슀트
1010
+ if 'interval_tasks' not in st.session_state:
1011
+ st.session_state.interval_tasks = []
1012
+
1013
+ if st.button("μ‹œκ°„ 간격 μ˜ˆμ•½ μΆ”κ°€"):
1014
+ st.session_state.interval_tasks.append({
1015
+ 'interval_minutes': interval_minutes,
1016
+ 'keyword': interval_keyword,
1017
+ 'num_articles': interval_num_articles,
1018
+ 'run_immediately': run_immediately
1019
+ })
1020
+ st.success(f"μ‹œκ°„ 간격 μ˜ˆμ•½μ΄ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€: {interval_minutes}λΆ„λ§ˆλ‹€ - '{interval_keyword}'")
1021
+
1022
+ # μ˜ˆμ•½ λͺ©λ‘ ν‘œμ‹œ
1023
+ if st.session_state.interval_tasks:
1024
+ st.subheader("μ‹œκ°„ 간격 μ˜ˆμ•½ λͺ©λ‘")
1025
+ for i, task in enumerate(st.session_state.interval_tasks):
1026
+ immediate_text = "μ¦‰μ‹œ μ‹€ν–‰ ν›„ " if task['run_immediately'] else ""
1027
+ st.write(f"{i+1}. {immediate_text}{task['interval_minutes']}λΆ„λ§ˆλ‹€ - '{task['keyword']}' ({task['num_articles']}개)")
1028
+
1029
+ if st.button("μ‹œκ°„ 간격 μ˜ˆμ•½ μ΄ˆκΈ°ν™”"):
1030
+ st.session_state.interval_tasks = []
1031
+ st.warning("μ‹œκ°„ 간격 μ˜ˆμ•½μ΄ λͺ¨λ‘ μ΄ˆκΈ°ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
1032
+
1033
+ # μŠ€μΌ€μ€„λŸ¬ μƒνƒœ νƒ­
1034
+ with tab3:
1035
+ st.subheader("μŠ€μΌ€μ€„λŸ¬ μ œμ–΄ 및 μƒνƒœ")
1036
+
1037
+ col1, col2 = st.columns(2)
1038
+
1039
+ with col1:
1040
+ # μŠ€μΌ€μ€„λŸ¬ μ‹œμž‘/쀑지 λ²„νŠΌ
1041
+ if not global_scheduler_state.is_running:
1042
+ if st.button("μŠ€μΌ€μ€„λŸ¬ μ‹œμž‘"):
1043
+ if not st.session_state.daily_tasks and not st.session_state.interval_tasks:
1044
+ st.error("μ˜ˆμ•½λœ μž‘μ—…μ΄ μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € 일별 μ˜ˆμ•½ λ˜λŠ” μ‹œκ°„ 간격 μ˜ˆμ•½μ„ μΆ”κ°€ν•΄μ£Όμ„Έμš”.")
1045
+ else:
1046
+ start_scheduler(st.session_state.daily_tasks, st.session_state.interval_tasks)
1047
+ st.success("μŠ€μΌ€μ€„λŸ¬κ°€ μ‹œμž‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
1048
+ else:
1049
+ if st.button("μŠ€μΌ€μ€„λŸ¬ 쀑지"):
1050
+ stop_scheduler()
1051
+ st.warning("μŠ€μΌ€μ€„λŸ¬κ°€ μ€‘μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
1052
+
1053
+ with col2:
1054
+ # μŠ€μΌ€μ€„λŸ¬ μƒνƒœ ν‘œμ‹œ
1055
+ if 'scheduler_status' in st.session_state:
1056
+ st.write(f"μƒνƒœ: {'싀행쀑' if global_scheduler_state.is_running else '쀑지'}")
1057
+ if global_scheduler_state.last_run:
1058
+ st.write(f"λ§ˆμ§€λ§‰ μ‹€ν–‰: {global_scheduler_state.last_run.strftime('%Y-%m-%d %H:%M:%S')}")
1059
+ if global_scheduler_state.next_run and global_scheduler_state.is_running:
1060
+ st.write(f"λ‹€μŒ μ‹€ν–‰: {global_scheduler_state.next_run.strftime('%Y-%m-%d %H:%M:%S')}")
1061
+ else:
1062
+ st.write("μƒνƒœ: 쀑지")
1063
+
1064
+ # μ˜ˆμ•½λœ μž‘μ—… λͺ©λ‘
1065
+ if global_scheduler_state.scheduled_jobs:
1066
+ st.subheader("ν˜„μž¬ μ‹€ν–‰ 쀑인 μ˜ˆμ•½ μž‘μ—…")
1067
+ for i, job in enumerate(global_scheduler_state.scheduled_jobs):
1068
+ if job['type'] == 'daily':
1069
+ st.write(f"{i+1}. [일별] 맀일 {job['time']} - '{job['keyword']}' ({job['num_articles']}개)")
1070
+ else:
1071
+ immediate_text = "[μ¦‰μ‹œ μ‹€ν–‰ ν›„] " if job.get('run_immediately', False) else ""
1072
+ st.write(f"{i+1}. [간격] {immediate_text}{job['interval']} - '{job['keyword']}' ({job['num_articles']}개)")
1073
+
1074
+ # μŠ€μΌ€μ€„λŸ¬ μ‹€ν–‰ κ²°κ³Ό
1075
+ if global_scheduler_state.scheduled_results:
1076
+ st.subheader("μŠ€μΌ€μ€„λŸ¬ μ‹€ν–‰ κ²°κ³Ό")
1077
+
1078
+ # κ²°κ³Όλ₯Ό UI에 ν‘œμ‹œν•˜κΈ° 전에 볡사
1079
+ results_for_display = global_scheduler_state.scheduled_results.copy()
1080
+
1081
+ if results_for_display:
1082
+ result_df = pd.DataFrame(results_for_display)
1083
+ result_df['μ‹€ν–‰μ‹œκ°„'] = result_df['timestamp'].apply(lambda x: datetime.strptime(x, "%Y%m%d_%H%M%S").strftime("%Y-%m-%d %H:%M:%S"))
1084
+ result_df = result_df.rename(columns={
1085
+ 'task_type': 'μž‘μ—…μœ ν˜•',
1086
+ 'keyword': 'ν‚€μ›Œλ“œ',
1087
+ 'num_articles': 'κΈ°μ‚¬μˆ˜',
1088
+ 'filename': '파일λͺ…'
1089
+ })
1090
+ result_df['μž‘μ—…μœ ν˜•'] = result_df['μž‘μ—…μœ ν˜•'].apply(lambda x: '일별' if x == 'daily' else 'μ‹œκ°„κ°„κ²©')
1091
+
1092
+ st.dataframe(
1093
+ result_df[['μž‘μ—…μœ ν˜•', 'ν‚€μ›Œλ“œ', 'κΈ°μ‚¬μˆ˜', 'μ‹€ν–‰μ‹œκ°„', '파일λͺ…']],
1094
+ hide_index=True
1095
+ )
1096
+
1097
+ # μˆ˜μ§‘λœ 파일 보기
1098
+ if os.path.exists('scheduled_news'):
1099
+ files = [f for f in os.listdir('scheduled_news') if f.endswith('.json')]
1100
+ if files:
1101
+ st.subheader("μˆ˜μ§‘λœ 파일 μ—΄κΈ°")
1102
+ selected_file = st.selectbox("파일 선택", files, index=len(files)-1)
1103
+ if selected_file and st.button("파일 λ‚΄μš© 보기"):
1104
+ with open(os.path.join('scheduled_news', selected_file), 'r', encoding='utf-8') as f:
1105
+ articles = json.load(f)
1106
+
1107
+ st.write(f"**파일λͺ…:** {selected_file}")
1108
+ st.write(f"**μˆ˜μ§‘ 기사 수:** {len(articles)}개")
1109
+
1110
+ for article in articles:
1111
+ with st.expander(f"{article['title']} - {article['source']}"):
1112
+ st.write(f"**좜처:** {article['source']}")
1113
+ st.write(f"**λ‚ μ§œ:** {article['date']}")
1114
+ st.write(f"**링크:** {article['link']}")
1115
+ st.write("**λ³Έλ¬Έ:**")
1116
+ st.write(article['content'][:500] + "..." if len(article['content']) > 500 else article['content'])
1117
 
1118
+ # ν‘Έν„°
1119
+ st.markdown("---")
1120
+ st.markdown("Β© λ‰΄μŠ€ 기사 도ꡬ @conanssam")