# app.py import os import streamlit as st import pandas as pd import requests from bs4 import BeautifulSoup import re import time import nltk from nltk.tokenize import word_tokenize from nltk.corpus import stopwords from collections import Counter import json from datetime import datetime, timedelta import openai import schedule import threading import matplotlib.pyplot as plt from wordcloud import WordCloud # ─── 설정: 임시 디렉토리, NLTK 데이터 ───────────────────────────────────────── # 임시 디렉토리 생성 TMP = "/tmp" NLP_DATA = os.path.join(TMP, "nltk_data") os.makedirs(NLP_DATA, exist_ok=True) # NLTK 데이터 검색 경로에 추가 nltk.data.path.insert(0, NLP_DATA) # 필요한 NLTK 리소스 다운로드 for pkg in ["punkt", "stopwords"]: try: nltk.data.find(f"tokenizers/{pkg}") except LookupError: nltk.download(pkg, download_dir=NLP_DATA) # ─── OpenAI API 키 불러오기 ──────────────────────────────────────────────────── # 우선 환경 변수, 그다음 st.secrets, 마지막으로 사이드바 입력 OPENAI_KEY = os.getenv("OPENAI_API_KEY") or st.secrets.get("OPENAI_API_KEY") if not OPENAI_KEY: # 앱 실행 중 사이드바에서 입력 받기 with st.sidebar: st.markdown("### 🔑 OpenAI API Key") key_input = st.text_input("Enter your OpenAI API Key:", type="password") if key_input: OPENAI_KEY = key_input if OPENAI_KEY: openai.api_key = OPENAI_KEY else: st.sidebar.error("OpenAI API Key가 설정되지 않았습니다.") # ─── Streamlit 페이지 & 메뉴 구성 ───────────────────────────────────────────── st.set_page_config(page_title="📰 News Tool", layout="wide") with st.sidebar: st.title("뉴스 기사 도구") menu = st.radio("메뉴 선택", [ "뉴스 기사 크롤링", "기사 분석하기", "새 기사 생성하기", "뉴스 기사 예약하기" ]) # ─── 파일 경로 헬퍼 ────────────────────────────────────────────────────────── def _tmp_path(*paths): """/tmp 하위 경로 조합""" full = os.path.join(TMP, *paths) os.makedirs(os.path.dirname(full), exist_ok=True) return full # ─── 저장된 기사 로드/저장 ─────────────────────────────────────────────────── def load_saved_articles(): path = _tmp_path("saved_articles", "articles.json") if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) return [] def save_articles(articles): path = _tmp_path("saved_articles", "articles.json") with open(path, "w", encoding="utf-8") as f: json.dump(articles, f, ensure_ascii=False, indent=2) # ─── 네이버 뉴스 크롤러 ───────────────────────────────────────────────────── @st.cache_data def crawl_naver_news(keyword, num_articles=5): url = f"https://search.naver.com/search.naver?where=news&query={keyword}" results = [] try: resp = requests.get(url, timeout=5) soup = BeautifulSoup(resp.text, "html.parser") items = soup.select("div.sds-comps-base-layout.sds-comps-full-layout") for i, it in enumerate(items): if i >= num_articles: break title_el = it.select_one("a.X0fMYp2dHd0TCUS2hjww span") link_el = it.select_one("a.X0fMYp2dHd0TCUS2hjww") src_el = it.select_one("div.sds-comps-profile-info-title span") date_el = it.select_one("span.r0VOr") desc_el = it.select_one("a.X0fMYp2dHd0TCUS2hjww.IaKmSOGPdofdPwPE6cyU > span") if not title_el or not link_el: continue results.append({ "title": title_el.text.strip(), "link": link_el["href"], "source": src_el.text.strip() if src_el else "알 수 없음", "date": date_el.text.strip() if date_el else "알 수 없음", "description": desc_el.text.strip() if desc_el else "", "content": "" }) except Exception as e: st.error(f"크롤링 오류: {e}") return results # ─── 기사 본문 가져오기 ─────────────────────────────────────────────────────── def get_article_content(url): try: resp = requests.get(url, timeout=5) soup = BeautifulSoup(resp.text, "html.parser") cont = soup.select_one("#dic_area") or soup.select_one(".article_body, .news-content-inner") if cont: text = re.sub(r"\s+", " ", cont.text.strip()) return text except Exception: pass return "본문을 가져올 수 없습니다." # ─── 키워드 분석 & 워드클라우드 ─────────────────────────────────────────────── def analyze_keywords(text, top_n=10): stop_kr = ["이","그","저","것","및","등","를","을","에","에서","의","으로","로"] tokens = [w for w in word_tokenize(text) if w.isalnum() and len(w)>1 and w not in stop_kr] freq = Counter(tokens) return freq.most_common(top_n) def extract_for_wordcloud(text, top_n=50): tokens = [w for w in word_tokenize(text.lower()) if w.isalnum()] stop_en = set(stopwords.words("english")) korea_sw = {"및","등","를","이","의","가","에","는"} sw = stop_en.union(korea_sw) filtered = [w for w in tokens if w not in sw and len(w)>1] freq = Counter(filtered) return dict(freq.most_common(top_n)) def generate_wordcloud(freq_dict): try: wc = WordCloud(width=800, height=400, background_color="white")\ .generate_from_frequencies(freq_dict) return wc except Exception as e: st.error(f"워드클라우드 생성 오류: {e}") return None # ─── OpenAI 기반 새 기사 & 이미지 생성 ─────────────────────────────────────── def generate_article(orig, prompt_text): if not openai.api_key: return "API Key가 설정되지 않았습니다." try: resp = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ {"role":"system","content":"당신은 전문 뉴스 기자입니다."}, {"role":"user", "content":f"{prompt_text}\n\n{orig[:1000]}"} ], max_tokens=1500 ) return resp.choices[0].message["content"] except Exception as e: return f"기사 생성 오류: {e}" def generate_image(prompt): if not openai.api_key: return None try: resp = openai.Image.create(prompt=prompt, n=1, size="512x512") return resp["data"][0]["url"] except Exception as e: st.error(f"이미지 생성 오류: {e}") return None # ─── 스케줄러 상태 클래스 ─────────────────────────────────────────────────── class SchedulerState: def __init__(self): self.is_running = False self.thread = None self.last_run = None self.next_run = None self.jobs = [] self.results = [] global_scheduler = SchedulerState() def perform_news_task(task_type, kw, n, prefix): arts = crawl_naver_news(kw, n) for a in arts: a["content"] = get_article_content(a["link"]) time.sleep(0.5) fname = _tmp_path("scheduled_news", f"{prefix}_{task_type}_{datetime.now():%Y%m%d_%H%M%S}.json") with open(fname,"w",encoding="utf-8") as f: json.dump(arts, f, ensure_ascii=False, indent=2) global_scheduler.last_run = datetime.now() global_scheduler.results.append({ "type":task_type, "keyword":kw, "count":len(arts), "file":fname, "timestamp":global_scheduler.last_run }) def run_scheduler(): while global_scheduler.is_running: schedule.run_pending() time.sleep(1) def start_scheduler(daily, interval): if global_scheduler.is_running: return schedule.clear(); global_scheduler.jobs=[] # 일별 for t in daily: hh, mm = t["hour"], t["minute"] tag = f"d_{t['keyword']}_{hh}{mm}" schedule.every().day.at(f"{hh:02d}:{mm:02d}")\ .do(perform_news_task,"daily",t["keyword"],t["num_articles"],tag).tag(tag) global_scheduler.jobs.append(tag) # 간격 for t in interval: tag = f"i_{t['keyword']}_{t['interval']}" if t["immediate"]: perform_news_task("interval", t["keyword"], t["num_articles"], tag) schedule.every(t["interval"]).minutes\ .do(perform_news_task,"interval",t["keyword"],t["num_articles"],tag).tag(tag) global_scheduler.jobs.append(tag) global_scheduler.next_run = schedule.next_run() global_scheduler.is_running = True th = threading.Thread(target=run_scheduler, daemon=True) th.start(); global_scheduler.thread = th def stop_scheduler(): global_scheduler.is_running = False schedule.clear() global_scheduler.jobs=[] # ─── 화면 그리기: 메뉴별 기능 ──────────────────────────────────────────────── if menu == "뉴스 기사 크롤링": st.header("뉴스 기사 크롤링") kw = st.text_input("🔍 검색어", "인공지능") num = st.slider("가져올 기사 수", 1, 20, 5) if st.button("기사 가져오기"): arts = crawl_naver_news(kw, num) for i,a in enumerate(arts): st.progress((i+1)/len(arts)) a["content"] = get_article_content(a["link"]) time.sleep(0.3) save_articles(arts) st.success(f"{len(arts)}개 기사 저장됨") for a in arts: with st.expander(a["title"]): st.write(f"출처: {a['source']} | 날짜: {a['date']}") st.write(a["description"]) st.write(a["content"][:300]+"…") elif menu == "기사 분석하기": st.header("기사 분석하기") arts = load_saved_articles() if not arts: st.warning("먼저 ‘뉴스 기사 크롤링’ 메뉴에서 기사를 수집하세요.") else: titles = [a["title"] for a in arts] sel = st.selectbox("분석할 기사 선택", titles) art = next(a for a in arts if a["title"]==sel) st.subheader(art["title"]) with st.expander("본문 보기"): st.write(art["content"]) mode = st.radio("분석 방식", ["키워드 분석", "텍스트 통계"]) if mode=="키워드 분석" and st.button("실행"): kw_list = analyze_keywords(art["content"]) df = pd.DataFrame(kw_list, columns=["단어","빈도"]) st.bar_chart(df.set_index("단어")) st.write("상위 키워드:") for w,c in kw_list: st.write(f"- {w}: {c}") # 워드클라우드 wc_data = extract_for_wordcloud(art["content"]) wc = generate_wordcloud(wc_data) if wc: fig,ax = plt.subplots(figsize=(8,4)) ax.imshow(wc,interp="bilinear"); ax.axis("off") st.pyplot(fig) if mode=="텍스트 통계" and st.button("실행"): txt=art["content"] wcnt=len(re.findall(r"\\w+",txt)) scnt=len(re.split(r"[.!?]+",txt)) st.metric("단어 수",wcnt); st.metric("문장 수",scnt) elif menu == "새 기사 생성하기": st.header("새 기사 생성하기") arts = load_saved_articles() if not arts: st.warning("먼저 기사를 수집해주세요.") else: sel = st.selectbox("원본 기사 선택", [a["title"] for a in arts]) art = next(a for a in arts if a["title"]==sel) st.write(art["content"][:200]+"…") prompt = st.text_area("기사 작성 지침", "기사 형식에 맞춰 새로 작성해 주세요.") gen_img = st.checkbox("이미지도 생성", value=True) if st.button("생성"): new = generate_article(art["content"], prompt) st.subheader("생성된 기사") st.write(new) if gen_img: url = generate_image(f"기사 제목: {art['title']}\n\n{prompt}") if url: st.image(url) elif menu == "뉴스 기사 예약하기": st.header("뉴스 기사 예약하기") tab1,tab2,tab3 = st.tabs(["일별 예약","간격 예약","상태"]) # 일별 with tab1: dkw = st.text_input("키워드(일별)", "인공지능", key="dk") dnum = st.number_input("기사 수",1,20,5,key="dn") dhh = st.number_input("시",0,23,9,key="dh") dmm = st.number_input("분",0,59,0,key="dm") if st.button("추가",key="addd"): st.session_state.setdefault("daily",[]).append({ "keyword":dkw,"num_articles":dnum, "hour":dhh,"minute":dmm }) if st.session_state.get("daily"): st.write(st.session_state["daily"]) # 간격 with tab2: ikw = st.text_input("키워드(간격)", "빅데이터", key="ik") inum = st.number_input("기사 수",1,20,5,key="in") inter= st.number_input("간격(분)",1,1440,60,key="ii") imm = st.checkbox("즉시 실행",True,key="im") if st.button("추가",key="addi"): st.session_state.setdefault("interval",[]).append({ "keyword":ikw,"num_articles":inum, "interval":inter,"immediate":imm }) if st.session_state.get("interval"): st.write(st.session_state["interval"]) # 상태 with tab3: if not global_scheduler.is_running and st.button("시작"): start_scheduler(st.session_state.get("daily",[]), st.session_state.get("interval",[])) if global_scheduler.is_running and st.button("중지"): stop_scheduler() st.write("실행중:", global_scheduler.is_running) st.write("마지막 실행:", global_scheduler.last_run) st.write("다음 실행:", global_scheduler.next_run) st.write("잡 수:", global_scheduler.jobs) st.dataframe(pd.DataFrame(global_scheduler.results)) # ─── 푸터 ──────────────────────────────────────────────────────────────────── st.markdown("---") st.markdown("© 2025 News Tool @conanssam")