JUNGU commited on
Commit
731c128
·
verified ·
1 Parent(s): 551eae9

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +1068 -271
src/streamlit_app.py CHANGED
@@ -1,5 +1,3 @@
1
- # app.py
2
- import os
3
  import streamlit as st
4
  import pandas as pd
5
  import requests
@@ -11,340 +9,1139 @@ from nltk.tokenize import word_tokenize
11
  from nltk.corpus import stopwords
12
  from collections import Counter
13
  import json
 
14
  from datetime import datetime, timedelta
15
  import openai
 
 
 
16
  import schedule
17
  import threading
18
  import matplotlib.pyplot as plt
19
- from wordcloud import WordCloud
20
 
21
- # ─── 설정: 임시 디렉토리, NLTK 데이터 ─────────────────────────────────────────
22
- # 임시 디렉토리 생성
23
- TMP = "/tmp"
24
- NLP_DATA = os.path.join(TMP, "nltk_data")
25
- os.makedirs(NLP_DATA, exist_ok=True)
26
 
27
- # NLTK 데이터 검색 경로에 추가
28
- nltk.data.path.insert(0, NLP_DATA)
 
 
 
 
29
 
30
- # 필요한 NLTK 리소스 다운로드
31
- for pkg in ["punkt", "stopwords"]:
32
- try:
33
- nltk.data.find(f"tokenizers/{pkg}")
34
- except LookupError:
35
- nltk.download(pkg, download_dir=NLP_DATA)
36
-
37
- # ─── OpenAI API 키 불러오기 ────────────────────────────────────────────────────
38
- # 우선 환경 변수, 그다음 st.secrets, 마지막으로 사이드바 입력
39
- OPENAI_KEY = os.getenv("OPENAI_API_KEY") or st.secrets.get("OPENAI_API_KEY")
40
- if not OPENAI_KEY:
41
- # 실행 중 사이드바에서 입력 받기
42
- with st.sidebar:
43
- st.markdown("### 🔑 OpenAI API Key")
44
- key_input = st.text_input("Enter your OpenAI API Key:", type="password")
45
- if key_input:
46
- OPENAI_KEY = key_input
47
-
48
- if OPENAI_KEY:
49
- openai.api_key = OPENAI_KEY
50
- else:
51
- st.sidebar.error("OpenAI API Key가 설정되지 않았습니다.")
52
 
53
- # ─── Streamlit 페이지 & 메뉴 구성 ─────────────────────────────────────────────
54
- st.set_page_config(page_title="📰 News Tool", layout="wide")
 
55
 
56
- with st.sidebar:
57
- st.title("뉴스 기사 도구")
58
- menu = st.radio("메뉴 선택", [
59
- "뉴스 기사 크롤링", "기사 분석하기", "새 기사 생성하기", "뉴스 기사 예약하기"
60
- ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- # ─── 파일 경로 헬퍼 ──────────────────────────────────────────────────────────
63
- def _tmp_path(*paths):
64
- """/tmp 하위 경로 조합"""
65
- full = os.path.join(TMP, *paths)
66
- os.makedirs(os.path.dirname(full), exist_ok=True)
67
- return full
68
 
69
- # ─── 저장된 기사 로드/저장 ───────────────────────────────────────────────────
 
 
 
 
 
 
 
70
  def load_saved_articles():
71
- path = _tmp_path("saved_articles", "articles.json")
72
- if os.path.exists(path):
73
- with open(path, "r", encoding="utf-8") as f:
74
  return json.load(f)
75
  return []
76
 
 
77
  def save_articles(articles):
78
- path = _tmp_path("saved_articles", "articles.json")
79
- with open(path, "w", encoding="utf-8") as f:
80
  json.dump(articles, f, ensure_ascii=False, indent=2)
81
 
82
- # ─── 네이버 뉴스 크롤러 ─────────────────────────────────────────────────────
83
  @st.cache_data
84
  def crawl_naver_news(keyword, num_articles=5):
 
 
 
85
  url = f"https://search.naver.com/search.naver?where=news&query={keyword}"
86
  results = []
 
87
  try:
88
- resp = requests.get(url, timeout=5)
89
- soup = BeautifulSoup(resp.text, "html.parser")
90
- items = soup.select("div.sds-comps-base-layout.sds-comps-full-layout")
91
- for i, it in enumerate(items):
92
- if i >= num_articles: break
93
- title_el = it.select_one("a.X0fMYp2dHd0TCUS2hjww span")
94
- link_el = it.select_one("a.X0fMYp2dHd0TCUS2hjww")
95
- src_el = it.select_one("div.sds-comps-profile-info-title span")
96
- date_el = it.select_one("span.r0VOr")
97
- desc_el = it.select_one("a.X0fMYp2dHd0TCUS2hjww.IaKmSOGPdofdPwPE6cyU > span")
98
- if not title_el or not link_el: continue
99
- results.append({
100
- "title": title_el.text.strip(),
101
- "link": link_el["href"],
102
- "source": src_el.text.strip() if src_el else "알 수 없음",
103
- "date": date_el.text.strip() if date_el else "알 수 없음",
104
- "description": desc_el.text.strip() if desc_el else "",
105
- "content": ""
106
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  except Exception as e:
108
- st.error(f"크롤링 오류: {e}")
 
109
  return results
110
 
111
- # ─── 기사 본문 가져오기 ───────────────────────────────────────────────────────
112
  def get_article_content(url):
113
  try:
114
- resp = requests.get(url, timeout=5)
115
- soup = BeautifulSoup(resp.text, "html.parser")
116
- cont = soup.select_one("#dic_area") or soup.select_one(".article_body, .news-content-inner")
117
- if cont:
118
- text = re.sub(r"\s+", " ", cont.text.strip())
 
 
 
119
  return text
120
- except Exception:
121
- pass
122
- return "본문을 가져올 없습니다."
 
 
 
 
 
 
 
 
123
 
124
- # ─── 키워드 분석 & 워드클라우드 ───────────────────────────────────────────────
125
  def analyze_keywords(text, top_n=10):
126
- stop_kr = ["이","그","저","것","및","등","를","을","에","에서","의","으로","로"]
127
- tokens = [w for w in word_tokenize(text) if w.isalnum() and len(w)>1 and w not in stop_kr]
128
- freq = Counter(tokens)
129
- return freq.most_common(top_n)
130
-
131
- def extract_for_wordcloud(text, top_n=50):
132
- tokens = [w for w in word_tokenize(text.lower()) if w.isalnum()]
133
- stop_en = set(stopwords.words("english"))
134
- korea_sw = {"및","등","를","이","의","가","에","는"}
135
- sw = stop_en.union(korea_sw)
136
- filtered = [w for w in tokens if w not in sw and len(w)>1]
137
- freq = Counter(filtered)
138
- return dict(freq.most_common(top_n))
139
-
140
- def generate_wordcloud(freq_dict):
 
141
  try:
142
- wc = WordCloud(width=800, height=400, background_color="white")\
143
- .generate_from_frequencies(freq_dict)
144
- return wc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  except Exception as e:
146
- st.error(f"워드클라우드 생성 오류: {e}")
147
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- # ─── OpenAI 기반 새 기사 & 이미지 생성 ───────────────────────────────────────
150
- def generate_article(orig, prompt_text):
151
- if not openai.api_key:
152
- return "API Key가 설정되지 않았습니다."
 
 
 
 
 
 
 
 
 
153
  try:
154
- resp = openai.ChatCompletion.create(
155
- model="gpt-3.5-turbo",
156
  messages=[
157
- {"role":"system","content":"당신은 전문 뉴스 기자입니다."},
158
- {"role":"user", "content":f"{prompt_text}\n\n{orig[:1000]}"}
159
  ],
160
- max_tokens=1500
161
  )
162
- return resp.choices[0].message["content"]
163
  except Exception as e:
164
- return f"기사 생성 오류: {e}"
165
 
 
166
  def generate_image(prompt):
167
- if not openai.api_key:
168
- return None
 
169
  try:
170
- resp = openai.Image.create(prompt=prompt, n=1, size="512x512")
171
- return resp["data"][0]["url"]
 
 
 
 
172
  except Exception as e:
173
- st.error(f"이미지 생성 오류: {e}")
174
- return None
175
 
176
- # ─── 스케줄러 상태 클래스 ───────────────────────────────────────────────────
177
- class SchedulerState:
178
- def __init__(self):
179
- self.is_running = False
180
- self.thread = None
181
- self.last_run = None
182
- self.next_run = None
183
- self.jobs = []
184
- self.results = []
185
- global_scheduler = SchedulerState()
186
-
187
- def perform_news_task(task_type, kw, n, prefix):
188
- arts = crawl_naver_news(kw, n)
189
- for a in arts:
190
- a["content"] = get_article_content(a["link"])
191
- time.sleep(0.5)
192
- fname = _tmp_path("scheduled_news", f"{prefix}_{task_type}_{datetime.now():%Y%m%d_%H%M%S}.json")
193
- with open(fname,"w",encoding="utf-8") as f:
194
- json.dump(arts, f, ensure_ascii=False, indent=2)
195
- global_scheduler.last_run = datetime.now()
196
- global_scheduler.results.append({
197
- "type":task_type, "keyword":kw,
198
- "count":len(arts), "file":fname,
199
- "timestamp":global_scheduler.last_run
200
- })
201
-
202
- def run_scheduler():
203
- while global_scheduler.is_running:
204
- schedule.run_pending()
205
- time.sleep(1)
206
-
207
- def start_scheduler(daily, interval):
208
- if global_scheduler.is_running: return
209
- schedule.clear(); global_scheduler.jobs=[]
210
- # 일별
211
- for t in daily:
212
- hh, mm = t["hour"], t["minute"]
213
- tag = f"d_{t['keyword']}_{hh}{mm}"
214
- schedule.every().day.at(f"{hh:02d}:{mm:02d}")\
215
- .do(perform_news_task,"daily",t["keyword"],t["num_articles"],tag).tag(tag)
216
- global_scheduler.jobs.append(tag)
217
- # 간격
218
- for t in interval:
219
- tag = f"i_{t['keyword']}_{t['interval']}"
220
- if t["immediate"]:
221
- perform_news_task("interval", t["keyword"], t["num_articles"], tag)
222
- schedule.every(t["interval"]).minutes\
223
- .do(perform_news_task,"interval",t["keyword"],t["num_articles"],tag).tag(tag)
224
- global_scheduler.jobs.append(tag)
225
-
226
- global_scheduler.next_run = schedule.next_run()
227
- global_scheduler.is_running = True
228
- th = threading.Thread(target=run_scheduler, daemon=True)
229
- th.start(); global_scheduler.thread = th
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  def stop_scheduler():
232
- global_scheduler.is_running = False
233
- schedule.clear()
234
- global_scheduler.jobs=[]
 
 
 
 
 
 
 
 
235
 
236
- # ─── 화면 그리기: 메뉴별 기능 ────────────────────────────────────────────────
237
  if menu == "뉴스 기사 크롤링":
238
  st.header("뉴스 기사 크롤링")
239
- kw = st.text_input("🔍 검색어", "인공지능")
240
- num = st.slider("가져올 기사 수", 1, 20, 5)
 
 
241
  if st.button("기사 가져오기"):
242
- arts = crawl_naver_news(kw, num)
243
- for i,a in enumerate(arts):
244
- st.progress((i+1)/len(arts))
245
- a["content"] = get_article_content(a["link"])
246
- time.sleep(0.3)
247
- save_articles(arts)
248
- st.success(f"{len(arts)}개 기사 저장됨")
249
- for a in arts:
250
- with st.expander(a["title"]):
251
- st.write(f"출처: {a['source']} | 날짜: {a['date']}")
252
- st.write(a["description"])
253
- st.write(a["content"][:300]+"…")
 
 
 
 
 
 
 
 
 
 
254
 
255
  elif menu == "기사 분석하기":
256
  st.header("기사 분석하기")
257
- arts = load_saved_articles()
258
- if not arts:
259
- st.warning("먼저 ‘뉴스 기사 크롤링’ 메뉴에서 기사를 수집하세요.")
 
260
  else:
261
- titles = [a["title"] for a in arts]
262
- sel = st.selectbox("분석할 기사 선택", titles)
263
- art = next(a for a in arts if a["title"]==sel)
264
- st.subheader(art["title"])
265
- with st.expander("본문 보기"):
266
- st.write(art["content"])
267
- mode = st.radio("분석 방식", ["키워드 분석", "텍스트 통계"])
268
- if mode=="키워드 분석" and st.button("실행"):
269
- kw_list = analyze_keywords(art["content"])
270
- df = pd.DataFrame(kw_list, columns=["단어","빈도"])
271
- st.bar_chart(df.set_index("단어"))
272
- st.write("상위 키워드:")
273
- for w,c in kw_list: st.write(f"- {w}: {c}")
274
- # 워드클라우드
275
- wc_data = extract_for_wordcloud(art["content"])
276
- wc = generate_wordcloud(wc_data)
277
- if wc:
278
- fig,ax = plt.subplots(figsize=(8,4))
279
- ax.imshow(wc,interp="bilinear"); ax.axis("off")
280
- st.pyplot(fig)
281
- if mode=="텍스트 통계" and st.button("실행"):
282
- txt=art["content"]
283
- wcnt=len(re.findall(r"\\w+",txt))
284
- scnt=len(re.split(r"[.!?]+",txt))
285
- st.metric("단어 수",wcnt); st.metric("문장 수",scnt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
  elif menu == "새 기사 생성하기":
288
  st.header("새 기사 생성하기")
289
- arts = load_saved_articles()
290
- if not arts:
291
- st.warning("먼저 기사를 수집해주세요.")
 
292
  else:
293
- sel = st.selectbox("원본 기사 선택", [a["title"] for a in arts])
294
- art = next(a for a in arts if a["title"]==sel)
295
- st.write(art["content"][:200]+"…")
296
- prompt = st.text_area("기사 작성 지침", "기사 형식에 맞춰 새로 작성해 주세요.")
297
- gen_img = st.checkbox("이미지도 생성", value=True)
298
- if st.button("생성"):
299
- new = generate_article(art["content"], prompt)
300
- st.subheader("생성된 기사")
301
- st.write(new)
302
- if gen_img:
303
- url = generate_image(f"기사 제목: {art['title']}\n\n{prompt}")
304
- if url: st.image(url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
  elif menu == "뉴스 기사 예약하기":
307
  st.header("뉴스 기사 예약하기")
308
- tab1,tab2,tab3 = st.tabs(["일별 예약","간격 예약","상태"])
309
- # 일별
 
 
 
310
  with tab1:
311
- dkw = st.text_input("키워드(일별)", "인공지능", key="dk")
312
- dnum = st.number_input("기사 수",1,20,5,key="dn")
313
- dhh = st.number_input("시",0,23,9,key="dh")
314
- dmm = st.number_input("",0,59,0,key="dm")
315
- if st.button("추가",key="addd"):
316
- st.session_state.setdefault("daily",[]).append({
317
- "keyword":dkw,"num_articles":dnum,
318
- "hour":dhh,"minute":dmm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  })
320
- if st.session_state.get("daily"):
321
- st.write(st.session_state["daily"])
322
- # 간격
 
 
 
 
 
 
 
 
 
 
323
  with tab2:
324
- ikw = st.text_input("키워드(간격)", "빅데이터", key="ik")
325
- inum = st.number_input("기사 수",1,20,5,key="in")
326
- inter= st.number_input("간격(분)",1,1440,60,key="ii")
327
- imm = st.checkbox("즉시 실행",True,key="im")
328
- if st.button("추가",key="addi"):
329
- st.session_state.setdefault("interval",[]).append({
330
- "keyword":ikw,"num_articles":inum,
331
- "interval":inter,"immediate":imm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  })
333
- if st.session_state.get("interval"):
334
- st.write(st.session_state["interval"])
335
- # 상태
 
 
 
 
 
 
 
 
 
 
 
336
  with tab3:
337
- if not global_scheduler.is_running and st.button("시작"):
338
- start_scheduler(st.session_state.get("daily",[]),
339
- st.session_state.get("interval",[]))
340
- if global_scheduler.is_running and st.button("중지"):
341
- stop_scheduler()
342
- st.write("실행중:", global_scheduler.is_running)
343
- st.write("마지막 실행:", global_scheduler.last_run)
344
- st.write("다음 실행:", global_scheduler.next_run)
345
- st.write("잡 수:", global_scheduler.jobs)
346
- st.dataframe(pd.DataFrame(global_scheduler.results))
347
-
348
- # ─── 푸터 ────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  st.markdown("---")
350
- st.markdown("© 2025 News Tool @conanssam")
 
 
 
1
  import streamlit as st
2
  import pandas as pd
3
  import requests
 
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
+ # /tmp 경로 설정
23
+ TMP_DIR = "/tmp"
24
+ SAVED_ARTICLES_PATH = os.path.join(TMP_DIR, "saved_articles.json")
25
+ SCHEDULED_NEWS_DIR = os.path.join(TMP_DIR, "scheduled_news")
 
26
 
27
+ # 워드클라우드 추가
28
+ try:
29
+ from wordcloud import WordCloud
30
+ except ImportError:
31
+ st.error("wordcloud 패키지를 설치해주세요: pip install wordcloud")
32
+ WordCloud = None
33
 
34
+ # 스케줄러 상태 클래스 추가
35
+ class SchedulerState:
36
+ def __init__(self):
37
+ self.is_running = False
38
+ self.thread = None
39
+ self.last_run = None
40
+ self.next_run = None
41
+ self.scheduled_jobs = []
42
+ self.scheduled_results = []
43
+
44
+ # 전역 스케줄러 상태 객체 생성 (스레드 안에서 사용)
45
+ global_scheduler_state = SchedulerState()
 
 
 
 
 
 
 
 
 
 
46
 
47
+ # API 관리를 위한 세션 상태 초기화
48
+ if 'openai_api_key' not in st.session_state:
49
+ st.session_state.openai_api_key = None
50
 
51
+ # API 키 로드 (허깅페이스 환경변수 우선, 다음으로 Streamlit secrets, 그 다음 .env 파일)
52
+ if st.session_state.openai_api_key is None:
53
+ st.session_state.openai_api_key = os.getenv('OPENAI_API_KEY') # Hugging Face
54
+ if st.session_state.openai_api_key is None:
55
+ try:
56
+ if 'OPENAI_API_KEY' in st.secrets: # Streamlit Cloud
57
+ st.session_state.openai_api_key = st.secrets['OPENAI_API_KEY']
58
+ except Exception: # st.secrets가 존재하지 않는 환경 (로컬 등)
59
+ pass
60
+ if st.session_state.openai_api_key is None:
61
+ load_dotenv() # 로컬 .env 파일
62
+ st.session_state.openai_api_key = os.getenv('OPENAI_API_KEY')
63
+
64
+ # 필요한 NLTK 데이터 다운로드
65
+ try:
66
+ nltk.data.find('tokenizers/punkt')
67
+ except LookupError:
68
+ nltk.download('punkt')
69
+
70
+ try:
71
+ nltk.data.find('tokenizers/punkt_tab')
72
+ except LookupError:
73
+ nltk.download('punkt_tab')
74
+
75
+ try:
76
+ nltk.data.find('corpora/stopwords')
77
+ except LookupError:
78
+ nltk.download('stopwords')
79
+
80
+ # OpenAI API 키 설정
81
+ # openai.api_key 설정은 각 API 호출 직전에 st.session_state.openai_api_key 사용하도록 변경하거나,
82
+ # 앱 시작 시점에 한 번 설정합니다. 여기서는 후자를 선택합니다.
83
+ if st.session_state.openai_api_key:
84
+ openai.api_key = st.session_state.openai_api_key
85
+ else:
86
+ # UI 초기에는 키가 없을 수 있으므로, 나중에 키 입력 시 openai.api_key가 설정되도록 유도
87
+ pass
88
 
89
+ # 페이지 설정
90
+ st.set_page_config(page_title="뉴스 기사 도구", page_icon="📰", layout="wide")
 
 
 
 
91
 
92
+ # 사이드바 메뉴 설정
93
+ st.sidebar.title("뉴스 기사 도구")
94
+ menu = st.sidebar.radio(
95
+ "메뉴 선택",
96
+ ["뉴스 기사 크롤링", "기사 분석하기", "새 기사 생성하기", "뉴스 기사 예약하기"]
97
+ )
98
+
99
+ # 저장된 기사를 불러오는 함수
100
  def load_saved_articles():
101
+ os.makedirs(TMP_DIR, exist_ok=True) # /tmp 디렉토리 생성 보장
102
+ if os.path.exists(SAVED_ARTICLES_PATH):
103
+ with open(SAVED_ARTICLES_PATH, 'r', encoding='utf-8') as f:
104
  return json.load(f)
105
  return []
106
 
107
+ # 기사를 저장하는 함수
108
  def save_articles(articles):
109
+ os.makedirs(TMP_DIR, exist_ok=True) # /tmp 디렉토리 생성 보장
110
+ with open(SAVED_ARTICLES_PATH, 'w', encoding='utf-8') as f:
111
  json.dump(articles, f, ensure_ascii=False, indent=2)
112
 
 
113
  @st.cache_data
114
  def crawl_naver_news(keyword, num_articles=5):
115
+ """
116
+ 네이버 뉴스 기사를 수집하는 함수
117
+ """
118
  url = f"https://search.naver.com/search.naver?where=news&query={keyword}"
119
  results = []
120
+
121
  try:
122
+ # 페이지 요청
123
+ response = requests.get(url)
124
+ soup = BeautifulSoup(response.text, 'html.parser')
125
+
126
+ # 뉴스 아이템 찾기
127
+ news_items = soup.select('div.sds-comps-base-layout.sds-comps-full-layout')
128
+
129
+ # 뉴스 아이템에서 정보 추출
130
+ for i, item in enumerate(news_items):
131
+ if i >= num_articles:
132
+ break
133
+
134
+ try:
135
+ # 제목과 링크 추출
136
+ title_element = item.select_one('a.X0fMYp2dHd0TCUS2hjww span')
137
+ if not title_element:
138
+ continue
139
+
140
+ title = title_element.text.strip()
141
+ link_element = item.select_one('a.X0fMYp2dHd0TCUS2hjww')
142
+ link = link_element['href'] if link_element else ""
143
+
144
+ # 언론사 추출
145
+ press_element = item.select_one('div.sds-comps-profile-info-title span.sds-comps-text-type-body2')
146
+ source = press_element.text.strip() if press_element else "알 수 없음"
147
+
148
+ # 날짜 추출
149
+ date_element = item.select_one('span.r0VOr')
150
+ date = date_element.text.strip() if date_element else "알 수 없음"
151
+
152
+ # 미리보기 내용 추출
153
+ desc_element = item.select_one('a.X0fMYp2dHd0TCUS2hjww.IaKmSOGPdofdPwPE6cyU > span')
154
+ description = desc_element.text.strip() if desc_element else "내용 없음"
155
+
156
+ results.append({
157
+ 'title': title,
158
+ 'link': link,
159
+ 'description': description,
160
+ 'source': source,
161
+ 'date': date,
162
+ 'content': "" # 나중에 원문 내용을 저장할 필드
163
+ })
164
+
165
+ except Exception as e:
166
+ st.error(f"기사 정보 추출 중 오류 발생: {str(e)}")
167
+ continue
168
+
169
  except Exception as e:
170
+ st.error(f"페이지 요청 중 오류 발생: {str(e)}")
171
+
172
  return results
173
 
174
+ # 기사 원문 가져오기
175
  def get_article_content(url):
176
  try:
177
+ response = requests.get(url, timeout=5)
178
+ soup = BeautifulSoup(response.text, 'html.parser')
179
+
180
+ # 네이버 뉴스 본문 찾기
181
+ content = soup.select_one('#dic_area')
182
+ if content:
183
+ text = content.text.strip()
184
+ text = re.sub(r'\s+', ' ', text) # 여러 공백 제거
185
  return text
186
+
187
+ # 다른 뉴스 사이트 본문 찾기 (여러 사이트 대응 필요)
188
+ content = soup.select_one('.article_body, .article-body, .article-content, .news-content-inner')
189
+ if content:
190
+ text = content.text.strip()
191
+ text = re.sub(r'\s+', ' ', text)
192
+ return text
193
+
194
+ return "본문을 가져올 수 없습니다."
195
+ except Exception as e:
196
+ return f"오류 발생: {str(e)}"
197
 
198
+ # NLTK를 이용한 키워드 분석
199
  def analyze_keywords(text, top_n=10):
200
+ # 한국어 불용어 목록 (직접 정의해야 합니다)
201
+ korean_stopwords = ['이', '그', '저', '것', '및', '등', '를', '을', '에', '에서', '의', '으로', '로']
202
+
203
+ tokens = word_tokenize(text)
204
+ tokens = [word for word in tokens if word.isalnum() and len(word) > 1 and word not in korean_stopwords]
205
+
206
+ word_count = Counter(tokens)
207
+ top_keywords = word_count.most_common(top_n)
208
+
209
+ return top_keywords
210
+
211
+ #워드 클라우드용 분석
212
+ def extract_keywords_for_wordcloud(text, top_n=50):
213
+ if not text or len(text.strip()) < 10:
214
+ return {}
215
+
216
  try:
217
+ try:
218
+ tokens = word_tokenize(text.lower())
219
+ except Exception as e:
220
+ st.warning(f"{str(e)} 오류발생")
221
+ tokens = text.lower().split()
222
+
223
+ stop_words = set()
224
+ try:
225
+ stop_words = set(stopwords.words('english'))
226
+ except Exception:
227
+ pass
228
+
229
+ korea_stop_words = {
230
+ '및', '등', '를', '이', '의', '가', '에', '는', '으로', '에서', '그', '또', '또는', '하는', '할', '하고',
231
+ '있다', '이다', '위해', '것이다', '것은', '대한', '때문', '그리고', '하지만', '그러나', '그래서',
232
+ '입니다', '합니다', '습니다', '요', '죠', '고', '과', '와', '도', '은', '수', '것', '들', '제', '저',
233
+ '년', '월', '일', '시', '분', '초', '지난', '올해', '내년', '최근', '현재', '오늘', '내일', '어제',
234
+ '오전', '오후', '부터', '까지', '에게', '께서', '이라고', '라고', '하며', '하면서', '따라', '통해',
235
+ '관련', '한편', '특히', '가장', '매우', '더', '덜', '많이', '조금', '항상', '자주', '가끔', '거의',
236
+ '전혀', '바로', '정말', '만약', '비롯한', '등을', '등이', '등의', '등과', '등도', '등에', '등에서',
237
+ '기자', '뉴스', '사진', '연합뉴스', '뉴시스', '제공', '무단', '전재', '재배포', '금지', '앵커', '멘트',
238
+ '일보', '데일리', '경제', '사회', '정치', '세계', '과학', '아이티', '닷컴', '씨넷', '블로터', '전자신문'
239
+ }
240
+ stop_words.update(korea_stop_words)
241
+
242
+ # 1글자 이상이고 불용어가 아닌 토큰만 필터��
243
+ filtered_tokens = [word for word in tokens if len(word) > 1 and word not in stop_words]
244
+
245
+ # 단어 빈도 계산
246
+ word_freq = {}
247
+ for word in filtered_tokens:
248
+ if word.isalnum(): # 알파벳과 숫자만 포함된 단어만 허용
249
+ word_freq[word] = word_freq.get(word, 0) + 1
250
+
251
+ # 빈도순으로 정렬하여 상위 n개 반환
252
+ sorted_words = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
253
+
254
+ if not sorted_words:
255
+ return {"data": 1, "analysis": 1, "news": 1}
256
+
257
+ return dict(sorted_words[:top_n])
258
+
259
  except Exception as e:
260
+ st.error(f"오류발생 {str(e)}")
261
+ return {"data": 1, "analysis": 1, "news": 1}
262
+
263
+
264
+ # 워드 클라우드 생성 함수
265
+
266
+ def generate_wordcloud(keywords_dict):
267
+ if not WordCloud:
268
+ st.warning("워드클라우드 설치안되어 있습니다.")
269
+ return None
270
+ try:
271
+ # 프로젝트 루트에 NanumGothic.ttf가 있다고 가정
272
+ font_path = "NanumGothic.ttf"
273
+
274
+ # 로컬에 폰트 파일이 있는지 확인, 없으면 기본으로 시도
275
+ if not os.path.exists(font_path):
276
+ st.warning(f"폰트 파일({font_path})을 찾을 수 없습니다. 기본 폰트로 워드클라우드를 생성합니다. 한글이 깨질 수 있습니다.")
277
+ # font_path = None # 또는 시스템 기본 폰트 경로를 지정 (플랫폼마다 다름)
278
+ # WordCloud 생성자에서 font_path를 None으로 두면 시스템 기본값을 시도하거나, 아예 빼고 호출
279
+ wc = WordCloud(
280
+ width=800,
281
+ height=400,
282
+ background_color='white',
283
+ colormap='viridis',
284
+ max_font_size=150,
285
+ random_state=42
286
+ ).generate_from_frequencies(keywords_dict)
287
+ else:
288
+ wc= WordCloud(
289
+ font_path=font_path,
290
+ width=800,
291
+ height=400,
292
+ background_color = 'white',
293
+ colormap = 'viridis',
294
+ max_font_size=150,
295
+ random_state=42
296
+ ).generate_from_frequencies(keywords_dict)
297
+
298
+ return wc
299
+
300
+ except Exception as e:
301
+ st.error(f"워드클라우드 생성 중 오류 발생: {str(e)}")
302
+ # traceback.print_exc() # 디버깅 시 사용
303
+ st.warning("워드클라우드 생성에 실패했습니다. 폰트 문제일 수 있습니다. NanumGothic.ttf 파일이 프로젝트 루트에 있는지 확인해주세요.")
304
+ return None
305
+
306
+ # 뉴스 분석 함수
307
+ def analyze_news_content(news_df):
308
+ if news_df.empty:
309
+ return "데이터가 없습니다"
310
+
311
+ results = {}
312
+ #카테고리별
313
+ if 'source' in news_df.columns:
314
+ results['source_counts'] = news_df['source'].value_counts().to_dict()
315
+ #카테고리별
316
+ if 'date' in news_df.columns:
317
+ results['date_counts'] = news_df['date'].value_counts().to_dict()
318
+
319
+ #키워드분석
320
+ all_text = " ".join(news_df['title'].fillna('') + " " + news_df['content'].fillna(''))
321
 
322
+ if len(all_text.strip()) > 0:
323
+ results['top_keywords_for_wordcloud']= extract_keywords_for_wordcloud(all_text, top_n=50)
324
+ results['top_keywords'] = analyze_keywords(all_text)
325
+ else:
326
+ results['top_keywords_for_wordcloud']={}
327
+ results['top_keywords'] = []
328
+ return results
329
+
330
+ # OpenAI API를 이용한 새 기사 생성
331
+ def generate_article(original_content, prompt_text):
332
+ if not st.session_state.openai_api_key:
333
+ return "오류: OpenAI API 키가 설정되지 않았습니다. 사이드바에서 키를 입력하거나 환경 변수를 설정해주세요."
334
+ openai.api_key = st.session_state.openai_api_key
335
  try:
336
+ response = openai.chat.completions.create(
337
+ model="gpt-4.1-mini",
338
  messages=[
339
+ {"role": "system", "content": "당신은 전문적인 뉴스 기자입니다. 주어진 내용을 바탕으로 새로운 기사를 작성해주세요."},
340
+ {"role": "user", "content": f"다음 내용을 바탕으로 {prompt_text}\n\n{original_content[:1000]}"}
341
  ],
342
+ max_tokens=2000
343
  )
344
+ return response.choices[0].message.content
345
  except Exception as e:
346
+ return f"기사 생성 오류: {str(e)}"
347
 
348
+ # OpenAI API를 이용한 이미지 생성
349
  def generate_image(prompt):
350
+ if not st.session_state.openai_api_key:
351
+ return "오류: OpenAI API 키가 설정되지 않았습니다. 사이드바에서 키를 입력하거나 환경 변수를 설정해주세요."
352
+ openai.api_key = st.session_state.openai_api_key
353
  try:
354
+ response = openai.images.generate(
355
+ model="gpt-image-1",
356
+ prompt=prompt
357
+ )
358
+ image_base64=response.data[0].b64_json
359
+ return f"data:image/png;base64,{image_base64}"
360
  except Exception as e:
361
+ return f"이미지 생성 오류: {str(e)}"
 
362
 
363
+ # 스케줄러 관련 함수들
364
+ def get_next_run_time(hour, minute):
365
+ now = datetime.now()
366
+ next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
367
+ if next_run <= now:
368
+ next_run += timedelta(days=1)
369
+ return next_run
370
+
371
+ def run_scheduled_task():
372
+ try:
373
+ while global_scheduler_state.is_running:
374
+ schedule.run_pending()
375
+ time.sleep(1)
376
+ except Exception as e:
377
+ print(f"스케줄러 에러 발생: {e}")
378
+ traceback.print_exc()
379
+
380
+ def perform_news_task(task_type, keyword, num_articles, file_prefix):
381
+ try:
382
+ articles = crawl_naver_news(keyword, num_articles)
383
+
384
+ # 기사 내용 가져오기
385
+ for article in articles:
386
+ article['content'] = get_article_content(article['link'])
387
+ time.sleep(0.5) # 서버 부하 방지
388
+
389
+ # 결과 저장
390
+ os.makedirs(SCHEDULED_NEWS_DIR, exist_ok=True) # 예약 뉴스 저장 디렉토리 생성
391
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
392
+ filename = os.path.join(SCHEDULED_NEWS_DIR, f"{file_prefix}_{task_type}_{timestamp}.json")
393
+
394
+ with open(filename, 'w', encoding='utf-8') as f:
395
+ json.dump(articles, f, ensure_ascii=False, indent=2)
396
+
397
+ global_scheduler_state.last_run = datetime.now()
398
+ print(f"{datetime.now()} - {task_type} 뉴스 기사 수집 완료: {keyword}")
399
+
400
+ # 전역 상태에 수집 결과를 저장 (UI 업데이트용)
401
+ result_item = {
402
+ 'task_type': task_type,
403
+ 'keyword': keyword,
404
+ 'timestamp': timestamp,
405
+ 'num_articles': len(articles),
406
+ 'filename': filename
407
+ }
408
+ global_scheduler_state.scheduled_results.append(result_item)
409
+
410
+ except Exception as e:
411
+ print(f"작업 실행 중 오류 발생: {e}")
412
+ traceback.print_exc()
413
+
414
+ def start_scheduler(daily_tasks, interval_tasks):
415
+ if not global_scheduler_state.is_running:
416
+ schedule.clear()
417
+ global_scheduler_state.scheduled_jobs = []
418
+
419
+ # 일별 태스크 등록
420
+ for task in daily_tasks:
421
+ hour = task['hour']
422
+ minute = task['minute']
423
+ keyword = task['keyword']
424
+ num_articles = task['num_articles']
425
+
426
+ job_id = f"daily_{keyword}_{hour}_{minute}"
427
+ schedule.every().day.at(f"{hour:02d}:{minute:02d}").do(
428
+ perform_news_task, "daily", keyword, num_articles, job_id
429
+ ).tag(job_id)
430
+
431
+ global_scheduler_state.scheduled_jobs.append({
432
+ 'id': job_id,
433
+ 'type': 'daily',
434
+ 'time': f"{hour:02d}:{minute:02d}",
435
+ 'keyword': keyword,
436
+ 'num_articles': num_articles
437
+ })
438
+
439
+ # 시간 간격 태스크 등록
440
+ for task in interval_tasks:
441
+ interval_minutes = task['interval_minutes']
442
+ keyword = task['keyword']
443
+ num_articles = task['num_articles']
444
+ run_immediately = task['run_immediately']
445
+
446
+ job_id = f"interval_{keyword}_{interval_minutes}"
447
+
448
+ if run_immediately:
449
+ # 즉시 실행
450
+ perform_news_task("interval", keyword, num_articles, job_id)
451
+
452
+ # 분 간격으로 예약
453
+ schedule.every(interval_minutes).minutes.do(
454
+ perform_news_task, "interval", keyword, num_articles, job_id
455
+ ).tag(job_id)
456
+
457
+ global_scheduler_state.scheduled_jobs.append({
458
+ 'id': job_id,
459
+ 'type': 'interval',
460
+ 'interval': f"{interval_minutes}분마다",
461
+ 'keyword': keyword,
462
+ 'num_articles': num_articles,
463
+ 'run_immediately': run_immediately
464
+ })
465
+
466
+ # 다음 실행 시간 계산
467
+ next_run = schedule.next_run()
468
+ if next_run:
469
+ global_scheduler_state.next_run = next_run
470
+
471
+ # 스케줄러 쓰레드 시작
472
+ global_scheduler_state.is_running = True
473
+ global_scheduler_state.thread = threading.Thread(
474
+ target=run_scheduled_task, daemon=True
475
+ )
476
+ global_scheduler_state.thread.start()
477
+
478
+ # 상태를 세션 상태로도 복사 (UI 표시용)
479
+ if 'scheduler_status' not in st.session_state:
480
+ st.session_state.scheduler_status = {}
481
+
482
+ st.session_state.scheduler_status = {
483
+ 'is_running': global_scheduler_state.is_running,
484
+ 'last_run': global_scheduler_state.last_run,
485
+ 'next_run': global_scheduler_state.next_run,
486
+ 'jobs_count': len(global_scheduler_state.scheduled_jobs)
487
+ }
488
 
489
  def stop_scheduler():
490
+ if global_scheduler_state.is_running:
491
+ global_scheduler_state.is_running = False
492
+ schedule.clear()
493
+ if global_scheduler_state.thread:
494
+ global_scheduler_state.thread.join(timeout=1)
495
+ global_scheduler_state.next_run = None
496
+ global_scheduler_state.scheduled_jobs = []
497
+
498
+ # UI 상태 업데이트
499
+ if 'scheduler_status' in st.session_state:
500
+ st.session_state.scheduler_status['is_running'] = False
501
 
502
+ # 메뉴에 따른 화면 표시
503
  if menu == "뉴스 기사 크롤링":
504
  st.header("뉴스 기사 크롤링")
505
+
506
+ keyword = st.text_input("검색어 입력", "인공지능")
507
+ num_articles = st.slider("가져올 기사 수", min_value=1, max_value=20, value=5)
508
+
509
  if st.button("기사 가져오기"):
510
+ with st.spinner("기사를 수집 중입니다..."):
511
+ articles = crawl_naver_news(keyword, num_articles)
512
+
513
+ # 기사 내용 가져오기
514
+ for i, article in enumerate(articles):
515
+ st.progress((i + 1) / len(articles))
516
+ article['content'] = get_article_content(article['link'])
517
+ time.sleep(0.5) # 서버 부하 방지
518
+
519
+ # 결과 저장 및 표시
520
+ save_articles(articles)
521
+ st.success(f"{len(articles)}개의 기사를 수집했습니다!")
522
+
523
+ # 수집한 기사 표시
524
+ for article in articles:
525
+ with st.expander(f"{article['title']} - {article['source']}"):
526
+ st.write(f"**출처:** {article['source']}")
527
+ st.write(f"**날짜:** {article['date']}")
528
+ st.write(f"**요약:** {article['description']}")
529
+ st.write(f"**링크:** {article['link']}")
530
+ st.write("**본문 미리보기:**")
531
+ st.write(article['content'][:300] + "...")
532
 
533
  elif menu == "기사 분석하기":
534
  st.header("기사 분석하기")
535
+
536
+ articles = load_saved_articles()
537
+ if not articles:
538
+ st.warning("저장된 기사가 없습니다. 먼저 '뉴스 기사 크롤링' 메뉴에서 기사를 수집해주세요.")
539
  else:
540
+ # 기사 선택
541
+ titles = [article['title'] for article in articles]
542
+ selected_title = st.selectbox("분석할 기사 선택", titles)
543
+
544
+ selected_article = next((a for a in articles if a['title'] == selected_title), None)
545
+
546
+ if selected_article:
547
+ st.write(f"**제목:** {selected_article['title']}")
548
+ st.write(f"**출처:** {selected_article['source']}")
549
+
550
+ # 본문 표시
551
+ with st.expander("기사 본문 보기"):
552
+ st.write(selected_article['content'])
553
+
554
+ # 분석 방법 선택
555
+ analysis_type = st.radio(
556
+ "분석 방법",
557
+ ["키워드 분석", "감정 분석", "텍스트 통계"]
558
+ )
559
+
560
+ if analysis_type == "키워드 분석":
561
+ if st.button("키워드 분석하기"):
562
+ with st.spinner("키워드를 분석 중입니다..."):
563
+ keyword_tab1, keyword_tab2 = st.tabs(["키워드 빈도", "워드클라우드"])
564
+
565
+ with keyword_tab1:
566
+
567
+ keywords = analyze_keywords(selected_article['content'])
568
+
569
+ # 시각화
570
+ df = pd.DataFrame(keywords, columns=['단어', '빈도수'])
571
+ st.bar_chart(df.set_index('단어'))
572
+
573
+ st.write("**주요 키워드:**")
574
+ for word, count in keywords:
575
+ st.write(f"- {word}: {count}회")
576
+ with keyword_tab2:
577
+ keyword_dict = extract_keywords_for_wordcloud(selected_article['content'])
578
+ wc = generate_wordcloud(keyword_dict)
579
+
580
+ if wc:
581
+ fig, ax = plt.subplots(figsize=(10, 5))
582
+ ax.imshow(wc, interpolation='bilinear')
583
+ ax.axis('off')
584
+ st.pyplot(fig)
585
+
586
+ # 키워드 상위 20개 표시
587
+ st.write("**상위 20개 키워드:**")
588
+ top_keywords = sorted(keyword_dict.items(), key=lambda x: x[1], reverse=True)[:20]
589
+ keyword_df = pd.DataFrame(top_keywords, columns=['키워드', '빈도'])
590
+ st.dataframe(keyword_df)
591
+ else:
592
+ st.error("워드클라우드를 생성할 수 없습니다.")
593
+
594
+ elif analysis_type == "텍스트 통계":
595
+ if st.button("텍스트 통계 분석"):
596
+ content = selected_article['content']
597
+
598
+ # 텍스트 통계 계산
599
+ word_count = len(re.findall(r'\b\w+\b', content))
600
+ char_count = len(content)
601
+ sentence_count = len(re.split(r'[.!?]+', content))
602
+ avg_word_length = sum(len(word) for word in re.findall(r'\b\w+\b', content)) / word_count if word_count > 0 else 0
603
+ avg_sentence_length = word_count / sentence_count if sentence_count > 0 else 0
604
+
605
+ # 통계 표시
606
+ st.subheader("텍스트 통계")
607
+ col1, col2, col3 = st.columns(3)
608
+ with col1:
609
+ st.metric("단어 수", f"{word_count:,}")
610
+ with col2:
611
+ st.metric("문자 수", f"{char_count:,}")
612
+ with col3:
613
+ st.metric("문장 수", f"{sentence_count:,}")
614
+
615
+ col1, col2 = st.columns(2)
616
+ with col1:
617
+ st.metric("평균 단어 길이", f"{avg_word_length:.1f}자")
618
+ with col2:
619
+ st.metric("평균 문장 길이", f"{avg_sentence_length:.1f}단어")
620
+
621
+ # 텍스트 복잡성 점수 (간단한 예시)
622
+ complexity_score = min(10, (avg_sentence_length / 10) * 5 + (avg_word_length / 5) * 5)
623
+ st.progress(complexity_score / 10)
624
+ st.write(f"텍스트 복잡성 점수: {complexity_score:.1f}/10")
625
+
626
+ # 출현 빈도 막대 그래프
627
+ st.subheader("품사별 분포 (한국어/영어 지원)")
628
+ try:
629
+ # KoNLPy 설치 확인
630
+ try:
631
+ from konlpy.tag import Okt
632
+ konlpy_installed = True
633
+ except ImportError:
634
+ konlpy_installed = False
635
+ st.warning("한국어 형태소 분석을 위해 KoNLPy를 설치해주세요: pip install konlpy")
636
+
637
+ # 영어 POS tagger 준비
638
+ from nltk import pos_tag
639
+ try:
640
+ nltk.data.find('taggers/averaged_perceptron_tagger')
641
+ except LookupError:
642
+ nltk.download('averaged_perceptron_tagger')
643
+
644
+ # Try using the correct resource name as shown in the error message
645
+ try:
646
+ nltk.data.find('averaged_perceptron_tagger_eng')
647
+ except LookupError:
648
+ nltk.download('averaged_perceptron_tagger_eng')
649
+
650
+ # 언어 감지 (간단한 방식)
651
+ is_korean = bool(re.search(r'[가-힣]', content))
652
+
653
+ if is_korean and konlpy_installed:
654
+ # 한국어 형태소 분석
655
+ okt = Okt()
656
+ tagged = okt.pos(content)
657
+
658
+ # 한국어 품사 매핑
659
+ pos_dict = {
660
+ 'Noun': '명사', 'NNG': '명사', 'NNP': '고유명사',
661
+ 'Verb': '동사', 'VV': '동사', 'VA': '형용사',
662
+ 'Adjective': '형용사',
663
+ 'Adverb': '부사',
664
+ 'Josa': '조사', 'Punctuation': '구두점',
665
+ 'Determiner': '관형사', 'Exclamation': '감탄사'
666
+ }
667
+
668
+ pos_counts = {'명사': 0, '동사': 0, '형용사': 0, '부사': 0, '조사': 0, '구두점': 0, '관형사': 0, '감탄사': 0, '기타': 0}
669
+
670
+ for _, pos in tagged:
671
+ if pos in pos_dict:
672
+ pos_counts[pos_dict[pos]] += 1
673
+ elif pos.startswith('N'): # 기타 명사류
674
+ pos_counts['명사'] += 1
675
+ elif pos.startswith('V'): # 기타 동사류
676
+ pos_counts['동사'] += 1
677
+ else:
678
+ pos_counts['기타'] += 1
679
+
680
+ else:
681
+ # 영어 POS 태깅
682
+ tokens = word_tokenize(content.lower())
683
+ tagged = pos_tag(tokens)
684
+
685
+ # 영어 품사 매핑
686
+ pos_dict = {
687
+ 'NN': '명사', 'NNS': '명사', 'NNP': '고유명사', 'NNPS': '고유명사',
688
+ 'VB': '동사', 'VBD': '동사', 'VBG': '동사', 'VBN': '동사', 'VBP': '동사', 'VBZ': '동사',
689
+ 'JJ': '형용사', 'JJR': '형용사', 'JJS': '형용사',
690
+ 'RB': '부사', 'RBR': '부사', 'RBS': '부사'
691
+ }
692
+
693
+ pos_counts = {'명사': 0, '동사': 0, '형용사': 0, '부사': 0, '기타': 0}
694
+
695
+ for _, pos in tagged:
696
+ if pos in pos_dict:
697
+ pos_counts[pos_dict[pos]] += 1
698
+ else:
699
+ pos_counts['기타'] += 1
700
+
701
+ # 결과 시각화
702
+ pos_df = pd.DataFrame({
703
+ '품사': list(pos_counts.keys()),
704
+ '빈도': list(pos_counts.values())
705
+ })
706
+
707
+ st.bar_chart(pos_df.set_index('품사'))
708
+
709
+ if is_korean:
710
+ st.info("한국어 텍스트가 감지되었습니다.")
711
+ else:
712
+ st.info("영어 텍스트가 감지되었습니다.")
713
+ except Exception as e:
714
+ st.error(f"품사 분석 중 오류 발생: {str(e)}")
715
+ st.error(traceback.format_exc())
716
+
717
+ elif analysis_type == "감정 분석":
718
+ if st.button("감정 분석하기"):
719
+ if st.session_state.openai_api_key:
720
+ with st.spinner("기사의 감정을 분석 중입니다..."):
721
+ try:
722
+ # 감정 분석 API 호출 전에 키 확인 및 설정
723
+ if not openai.api_key:
724
+ if st.session_state.openai_api_key:
725
+ openai.api_key = st.session_state.openai_api_key
726
+ else:
727
+ st.error("OpenAI API 키가 설정되지 않았습니다.")
728
+ st.stop()
729
+
730
+ response = openai.chat.completions.create(
731
+ model="gpt-4.1-mini",
732
+ messages=[
733
+ {"role": "system", "content": "당신은 텍스트의 감정과 논조를 분석하는 전문가입니다. 다음 뉴스 기사의 감정과 논조를 분석하고, '긍정적', '부정적', '중립적' 중 하나로 분류해 주세요. 또한 기사에서 드러나는 핵심 감정 키워드를 5개 추출하고, 각 키워드별로 1-10 사이의 강도 점수를 매겨주세요. JSON 형식으로 다음과 같이 응답해주세요: {'sentiment': '긍정적/부정적/중립적', 'reason': '이유 설명...', 'keywords': [{'word': '키워드1', 'score': 8}, {'word': '키워드2', 'score': 7}, ...]}"},
734
+ {"role": "user", "content": f"다음 뉴스 기사를 분석해 주세요:\n\n제목: {selected_article['title']}\n\n내용: {selected_article['content'][:1500]}"}
735
+ ],
736
+ max_tokens=800,
737
+ response_format={"type": "json_object"}
738
+ )
739
+
740
+ # JSON 파싱
741
+ analysis_result = json.loads(response.choices[0].message.content)
742
+
743
+ # 결과 시각화
744
+ st.subheader("감정 분석 결과")
745
+
746
+ # 1. 감정 타입에 따른 시각적 표현
747
+ sentiment_type = analysis_result.get('sentiment', '중립적')
748
+ col1, col2, col3 = st.columns([1, 3, 1])
749
+
750
+ with col2:
751
+ if sentiment_type == "긍정적":
752
+ st.markdown(f"""
753
+ <div style="background-color:#DCEDC8; padding:20px; border-radius:10px; text-align:center;">
754
+ <h1 style="color:#388E3C; font-size:28px;">😀 긍정적 논조 😀</h1>
755
+ <p style="font-size:16px;">감정 강도: 높음</p>
756
+ </div>
757
+ """, unsafe_allow_html=True)
758
+ elif sentiment_type == "부정적":
759
+ st.markdown(f"""
760
+ <div style="background-color:#FFCDD2; padding:20px; border-radius:10px; text-align:center;">
761
+ <h1 style="color:#D32F2F; font-size:28px;">😞 부정적 논조 😞</h1>
762
+ <p style="font-size:16px;">감정 강도: 높음</p>
763
+ </div>
764
+ """, unsafe_allow_html=True)
765
+ else:
766
+ st.markdown(f"""
767
+ <div style="background-color:#E0E0E0; padding:20px; border-radius:10px; text-align:center;">
768
+ <h1 style="color:#616161; font-size:28px;">😐 중립적 논조 😐</h1>
769
+ <p style="font-size:16px;">감정 강도: 중간</p>
770
+ </div>
771
+ """, unsafe_allow_html=True)
772
+
773
+ # 2. 이유 설명
774
+ st.markdown("### 분석 근거")
775
+ st.markdown(f"<div style='background-color:#F5F5F5; padding:15px; border-radius:5px;'>{analysis_result.get('reason', '')}</div>", unsafe_allow_html=True)
776
+
777
+ # 3. 감정 키워드 시각화
778
+ st.markdown("### 핵심 감정 키워드")
779
+
780
+ # 키워드 데이터 준비
781
+ keywords = analysis_result.get('keywords', [])
782
+ if keywords:
783
+ # 막대 차트용 데이터
784
+ keyword_names = [item.get('word', '') for item in keywords]
785
+ keyword_scores = [item.get('score', 0) for item in keywords]
786
+
787
+ # 레이더 차트 생성
788
+ fig = go.Figure()
789
+
790
+ # 색상 설정
791
+ if sentiment_type == "긍정적":
792
+ fill_color = 'rgba(76, 175, 80, 0.3)' # 연한 초록색
793
+ line_color = 'rgba(76, 175, 80, 1)' # 진한 초록색
794
+ elif sentiment_type == "부정적":
795
+ fill_color = 'rgba(244, 67, 54, 0.3)' # 연한 빨간색
796
+ line_color = 'rgba(244, 67, 54, 1)' # 진한 빨간색
797
+ else:
798
+ fill_color = 'rgba(158, 158, 158, 0.3)' # 연한 회색
799
+ line_color = 'rgba(158, 158, 158, 1)' # 진한 회색
800
+
801
+ # 레이더 차트 데이터 준비 - 마지막 점이 첫 점과 연결되도록 데이터 추가
802
+ radar_keywords = keyword_names.copy()
803
+ radar_scores = keyword_scores.copy()
804
+
805
+ # 레이더 차트 생성
806
+ fig.add_trace(go.Scatterpolar(
807
+ r=radar_scores,
808
+ theta=radar_keywords,
809
+ fill='toself',
810
+ fillcolor=fill_color,
811
+ line=dict(color=line_color, width=2),
812
+ name='감정 키워드'
813
+ ))
814
+
815
+ # 레이더 차트 레이아웃 설정
816
+ fig.update_layout(
817
+ polar=dict(
818
+ radialaxis=dict(
819
+ visible=True,
820
+ range=[0, 10],
821
+ tickmode='linear',
822
+ tick0=0,
823
+ dtick=2
824
+ )
825
+ ),
826
+ showlegend=False,
827
+ title={
828
+ 'text': '감정 키워드 레이더 분석',
829
+ 'y':0.95,
830
+ 'x':0.5,
831
+ 'xanchor': 'center',
832
+ 'yanchor': 'top'
833
+ },
834
+ height=500,
835
+ width=500,
836
+ margin=dict(l=80, r=80, t=80, b=80)
837
+ )
838
+
839
+ # 차트 중앙에 표시
840
+ col1, col2, col3 = st.columns([1, 2, 1])
841
+ with col2:
842
+ st.plotly_chart(fig)
843
+
844
+ # 키워드 카드로 표시
845
+ st.markdown("#### 키워드 세부 설명")
846
+ cols = st.columns(min(len(keywords), 5))
847
+ for i, keyword in enumerate(keywords):
848
+ with cols[i % len(cols)]:
849
+ word = keyword.get('word', '')
850
+ score = keyword.get('score', 0)
851
+
852
+ # 점수에 따른 색상 계산
853
+ r, g, b = 0, 0, 0
854
+ if sentiment_type == "긍정적":
855
+ g = min(200 + score * 5, 255)
856
+ r = max(255 - score * 20, 100)
857
+ elif sentiment_type == "부정적":
858
+ r = min(200 + score * 5, 255)
859
+ g = max(255 - score * 20, 100)
860
+ else:
861
+ r = g = b = 128
862
+
863
+ # 카드 생성
864
+ st.markdown(f"""
865
+ <div style="background-color:rgba({r},{g},{b},0.2); padding:10px; border-radius:5px; text-align:center; margin:5px;">
866
+ <h3 style="margin:0;">{word}</h3>
867
+ <div style="background-color:#E0E0E0; border-radius:3px; margin-top:5px;">
868
+ <div style="width:{score*10}%; background-color:rgba({r},{g},{b},0.8); height:10px; border-radius:3px;"></div>
869
+ </div>
870
+ <p style="margin:2px; font-size:12px;">강도: {score}/10</p>
871
+ </div>
872
+ """, unsafe_allow_html=True)
873
+
874
+ else:
875
+ st.info("키워드를 추출하지 못했습니다.")
876
+
877
+ # 4. 요약 통계
878
+ st.markdown("### 주요 통계")
879
+ col1, col2, col3 = st.columns(3)
880
+ with col1:
881
+ st.metric(label="긍정/부정 점수", value=f"{7 if sentiment_type == '긍정적' else 3 if sentiment_type == '부정적' else 5}/10")
882
+ with col2:
883
+ st.metric(label="키워드 수", value=len(keywords))
884
+ with col3:
885
+ avg_score = sum(keyword_scores) / len(keyword_scores) if keyword_scores else 0
886
+ st.metric(label="평균 강도", value=f"{avg_score:.1f}/10")
887
+
888
+ except Exception as e:
889
+ st.error(f"감정 분석 오류: {str(e)}")
890
+ st.code(traceback.format_exc())
891
+ else:
892
+ st.warning("OpenAI API 키가 설정되어 있지 않습니다. 사이드바에서 API 키를 설정해주세요.")
893
 
894
  elif menu == "새 기사 생성하기":
895
  st.header("새 기사 생성하기")
896
+
897
+ articles = load_saved_articles()
898
+ if not articles:
899
+ st.warning("저장된 기사가 없습니다. 먼저 '뉴스 기사 크롤링' 메뉴에서 기사를 수집해주세요.")
900
  else:
901
+ # 기사 선택
902
+ titles = [article['title'] for article in articles]
903
+ selected_title = st.selectbox("원본 기사 선택", titles)
904
+
905
+ selected_article = next((a for a in articles if a['title'] == selected_title), None)
906
+
907
+ if selected_article:
908
+ st.write(f"**원본 제목:** {selected_article['title']}")
909
+
910
+ with st.expander("원본 기사 내용"):
911
+ st.write(selected_article['content'])
912
+
913
+ prompt_text ="""다음 기사 양식을 따라서 다시 작성해줘.
914
+ 역할: 당신은 신문사의 기자입니다.
915
+ 작업: 최근 일어난 사건에 대한 보도자료를 작성해야 합니다. 자료는 사실을 기반으로 하며, 객관적이고 정확해야 합니다.
916
+ 지침:
917
+ 제공된 정보를 바탕으로 신문 보도자료 형식에 맞춰 기사를 작성하세요.
918
+ 기사 제목은 주제를 명확히 반영하고 독자의 관심을 끌 수 있도록 작성합니다.
919
+ 기사 내용은 정확하고 간결하며 설득력 있는 문장으로 구성합니다.
920
+ 관련자의 인터뷰를 인용 형태로 넣어주세요.
921
+ 위의 정보와 지침을 참고하여 신문 보도자료 형식의 기사를 작성해 주세요"""
922
+
923
+ # 이미지 생성 여부 선택 옵션 추가
924
+ generate_image_too = st.checkbox("기사 생성 후 이미지도 함께 생성하기", value=True)
925
+
926
+ if st.button("새 기사 생성하기"):
927
+ if st.session_state.openai_api_key:
928
+ # openai.api_key = st.session_state.openai_api_key # 이미 상단에서 설정됨 또는 각 함수 호출 시 설정
929
+ with st.spinner("기사를 생성 중입니다..."):
930
+ new_article = generate_article(selected_article['content'], prompt_text)
931
+
932
+ st.write("**생성된 기사:**")
933
+ st.write(new_article)
934
+
935
+ # 이미지 생성하기 (옵션이 선택된 경우)
936
+ if generate_image_too:
937
+ with st.spinner("기사 관련 이미지를 생성 중입니다..."):
938
+ # 이미지 생성 프롬프트 준비
939
+ image_prompt = f"""신문기사 제목 "{selected_article['title']}" 을 보고 이미지를 만들어줘
940
+ 이미지에는 다음 요소가 포함되어야 합니다:
941
+ - 기사를 이해할 수 있는 도식
942
+ - 기사 내용과 관련된 텍스트
943
+ - 심플하게 처리
944
+ """
945
+
946
+ # 이미지 생성
947
+ # 이미지 생성 API 호출 전에 키 확인 및 설정
948
+ if not openai.api_key:
949
+ if st.session_state.openai_api_key:
950
+ openai.api_key = st.session_state.openai_api_key
951
+ else:
952
+ st.error("OpenAI API 키가 설정되지 않았습니다.")
953
+ st.stop()
954
+ image_url = generate_image(image_prompt)
955
+
956
+ if image_url and not image_url.startswith("이미지 생성 오류") and not image_url.startswith("오류: OpenAI API 키가 설정되지 않았습니다."):
957
+ st.subheader("생성된 이미지:")
958
+ st.image(image_url)
959
+ else:
960
+ st.error(image_url)
961
+
962
+ # 생성된 기사 저장 옵션
963
+ if st.button("생성된 기사 저장"):
964
+ new_article_data = {
965
+ 'title': f"[생성됨] {selected_article['title']}",
966
+ 'source': f"AI 생성 (원본: {selected_article['source']})",
967
+ 'date': datetime.now().strftime("%Y-%m-%d %H:%M"),
968
+ 'description': new_article[:100] + "...",
969
+ 'link': "",
970
+ 'content': new_article
971
+ }
972
+ articles.append(new_article_data)
973
+ save_articles(articles)
974
+ st.success("생성된 기사가 저장되었습니다!")
975
+ else:
976
+ st.warning("OpenAI API 키를 사이드바에서 설정해주세요.")
977
 
978
  elif menu == "뉴스 기사 예약하기":
979
  st.header("뉴스 기사 예약하기")
980
+
981
+ # 탭 생성
982
+ tab1, tab2, tab3 = st.tabs(["일별 예약", "시간 간격 예약", "스케줄러 상태"])
983
+
984
+ # 일별 예약 탭
985
  with tab1:
986
+ st.subheader("매일 정해진 시간에 기사 수집하기")
987
+
988
+ # 키워드 입력
989
+ daily_keyword = st.text_input("검색 키워드", value="인공지능", key="daily_keyword")
990
+ daily_num_articles = st.slider("수집할 기사 수", min_value=1, max_value=20, value=5, key="daily_num_articles")
991
+
992
+ # 시간 설정
993
+ daily_col1, daily_col2 = st.columns(2)
994
+ with daily_col1:
995
+ daily_hour = st.selectbox("시", range(24), format_func=lambda x: f"{x:02d}시", key="daily_hour")
996
+ with daily_col2:
997
+ daily_minute = st.selectbox("분", range(0, 60, 5), format_func=lambda x: f"{x:02d}분", key="daily_minute")
998
+
999
+ # 일별 예약 리스트
1000
+ if 'daily_tasks' not in st.session_state:
1001
+ st.session_state.daily_tasks = []
1002
+
1003
+ if st.button("일별 예약 추가"):
1004
+ st.session_state.daily_tasks.append({
1005
+ 'hour': daily_hour,
1006
+ 'minute': daily_minute,
1007
+ 'keyword': daily_keyword,
1008
+ 'num_articles': daily_num_articles
1009
  })
1010
+ st.success(f"일별 예약이 추가되었습니다: 매일 {daily_hour:02d}:{daily_minute:02d} - '{daily_keyword}'")
1011
+
1012
+ # 예약 목록 표시
1013
+ if st.session_state.daily_tasks:
1014
+ st.subheader("일별 예약 목록")
1015
+ for i, task in enumerate(st.session_state.daily_tasks):
1016
+ st.write(f"{i+1}. 매일 {task['hour']:02d}:{task['minute']:02d} - '{task['keyword']}' ({task['num_articles']}개)")
1017
+
1018
+ if st.button("일별 예약 초기화"):
1019
+ st.session_state.daily_tasks = []
1020
+ st.warning("일별 예약이 모두 초기화되었습니다.")
1021
+
1022
+ # 시간 간격 예약 탭
1023
  with tab2:
1024
+ st.subheader("시간 간격으로 기사 수집하기")
1025
+
1026
+ # 키워드 입력
1027
+ interval_keyword = st.text_input("검색 키워드", value="빅데이터", key="interval_keyword")
1028
+ interval_num_articles = st.slider("수집할 기사 수", min_value=1, max_value=20, value=5, key="interval_num_articles")
1029
+
1030
+ # 시간 간격 설정
1031
+ interval_minutes = st.number_input("실행 간격(분)", min_value=1, max_value=60*24, value=30, key="interval_minutes")
1032
+
1033
+ # 즉시 실행 여부
1034
+ run_immediately = st.checkbox("즉시 실행", value=True, help="체크하면 스케줄러 시작 시 즉시 실행합니다.")
1035
+
1036
+ # 시간 간격 예약 리스트
1037
+ if 'interval_tasks' not in st.session_state:
1038
+ st.session_state.interval_tasks = []
1039
+
1040
+ if st.button("시간 간격 예약 추가"):
1041
+ st.session_state.interval_tasks.append({
1042
+ 'interval_minutes': interval_minutes,
1043
+ 'keyword': interval_keyword,
1044
+ 'num_articles': interval_num_articles,
1045
+ 'run_immediately': run_immediately
1046
  })
1047
+ st.success(f"시간 간격 예약이 추가되었습니다: {interval_minutes}분마다 - '{interval_keyword}'")
1048
+
1049
+ # 예약 목록 표시
1050
+ if st.session_state.interval_tasks:
1051
+ st.subheader("시간 간격 예약 목록")
1052
+ for i, task in enumerate(st.session_state.interval_tasks):
1053
+ immediate_text = "즉시 실행 후 " if task['run_immediately'] else ""
1054
+ st.write(f"{i+1}. {immediate_text}{task['interval_minutes']}분마다 - '{task['keyword']}' ({task['num_articles']}개)")
1055
+
1056
+ if st.button("시간 간격 예약 초기화"):
1057
+ st.session_state.interval_tasks = []
1058
+ st.warning("시간 간격 예약이 모두 초기화되었습니다.")
1059
+
1060
+ # 스케줄러 상태 탭
1061
  with tab3:
1062
+ st.subheader("스케줄러 제어 및 상태")
1063
+
1064
+ col1, col2 = st.columns(2)
1065
+
1066
+ with col1:
1067
+ # 스케줄러 시작/중지 버튼
1068
+ if not global_scheduler_state.is_running:
1069
+ if st.button("스케줄러 시작"):
1070
+ if not st.session_state.daily_tasks and not st.session_state.interval_tasks:
1071
+ st.error("예약된 작업이 없습니다. 먼저 일별 예약 또는 시간 간격 예약을 추가해주세요.")
1072
+ else:
1073
+ start_scheduler(st.session_state.daily_tasks, st.session_state.interval_tasks)
1074
+ st.success("스케줄러가 시작되었습니다.")
1075
+ else:
1076
+ if st.button("스케줄러 중지"):
1077
+ stop_scheduler()
1078
+ st.warning("스케줄러가 중지되었습니다.")
1079
+
1080
+ with col2:
1081
+ # 스케줄러 상태 표시
1082
+ if 'scheduler_status' in st.session_state:
1083
+ st.write(f"상태: {'실행중' if global_scheduler_state.is_running else '중지'}")
1084
+ if global_scheduler_state.last_run:
1085
+ st.write(f"마지막 실행: {global_scheduler_state.last_run.strftime('%Y-%m-%d %H:%M:%S')}")
1086
+ if global_scheduler_state.next_run and global_scheduler_state.is_running:
1087
+ st.write(f"다음 실행: {global_scheduler_state.next_run.strftime('%Y-%m-%d %H:%M:%S')}")
1088
+ else:
1089
+ st.write("상태: 중지")
1090
+
1091
+ # 예약된 작업 목록
1092
+ if global_scheduler_state.scheduled_jobs:
1093
+ st.subheader("현재 실행 중인 예약 작업")
1094
+ for i, job in enumerate(global_scheduler_state.scheduled_jobs):
1095
+ if job['type'] == 'daily':
1096
+ st.write(f"{i+1}. [일별] 매일 {job['time']} - '{job['keyword']}' ({job['num_articles']}개)")
1097
+ else:
1098
+ immediate_text = "[즉시 실행 후] " if job.get('run_immediately', False) else ""
1099
+ st.write(f"{i+1}. [간격] {immediate_text}{job['interval']} - '{job['keyword']}' ({job['num_articles']}개)")
1100
+
1101
+ # 스케줄러 실행 결과
1102
+ if global_scheduler_state.scheduled_results:
1103
+ st.subheader("스케줄러 실행 결과")
1104
+
1105
+ # 결과를 UI에 표시하기 전에 복사
1106
+ results_for_display = global_scheduler_state.scheduled_results.copy()
1107
+
1108
+ if results_for_display:
1109
+ result_df = pd.DataFrame(results_for_display)
1110
+ result_df['실행시간'] = result_df['timestamp'].apply(lambda x: datetime.strptime(x, "%Y%m%d_%H%M%S").strftime("%Y-%m-%d %H:%M:%S"))
1111
+ result_df = result_df.rename(columns={
1112
+ 'task_type': '작업유형',
1113
+ 'keyword': '키워드',
1114
+ 'num_articles': '기사수',
1115
+ 'filename': '파일명'
1116
+ })
1117
+ result_df['작업유형'] = result_df['작업유형'].apply(lambda x: '일별' if x == 'daily' else '시간간격')
1118
+
1119
+ st.dataframe(
1120
+ result_df[['작업유형', '키워드', '기사수', '실행시간', '파일명']],
1121
+ hide_index=True
1122
+ )
1123
+
1124
+ # 수집된 파일 보기
1125
+ if os.path.exists(SCHEDULED_NEWS_DIR):
1126
+ files = [f for f in os.listdir(SCHEDULED_NEWS_DIR) if f.endswith('.json')]
1127
+ if files:
1128
+ st.subheader("수집된 파일 열기")
1129
+ selected_file = st.selectbox("파일 선택", files, index=len(files)-1 if files else 0) # files가 비어있을 경우 대비
1130
+ if selected_file and st.button("파일 내용 보기"):
1131
+ with open(os.path.join(SCHEDULED_NEWS_DIR, selected_file), 'r', encoding='utf-8') as f:
1132
+ articles = json.load(f)
1133
+
1134
+ st.write(f"**파일명:** {selected_file}")
1135
+ st.write(f"**수집 기사 수:** {len(articles)}개")
1136
+
1137
+ for article in articles:
1138
+ with st.expander(f"{article['title']} - {article['source']}"):
1139
+ st.write(f"**출처:** {article['source']}")
1140
+ st.write(f"**날짜:** {article['date']}")
1141
+ st.write(f"**링크:** {article['link']}")
1142
+ st.write("**본문:**")
1143
+ st.write(article['content'][:500] + "..." if len(article['content']) > 500 else article['content'])
1144
+
1145
+ # 푸터
1146
  st.markdown("---")
1147
+ st.markdown("© 뉴스 기사 도구 @conanssam")