import os import random import time import re import json import requests from bs4 import BeautifulSoup from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry import openai import gradio as gr from fpdf import FPDF as FPDF2 from datetime import datetime from zoneinfo import ZoneInfo import sys import logging # API 키 설정 OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") # OpenAI 설정 openai.api_key = OPENAI_API_KEY def setup_session(): try: session = requests.Session() retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504]) session.mount('https://', HTTPAdapter(max_retries=retries)) return session except Exception as e: return None def generate_naver_search_url(query): base_url = "https://search.naver.com/search.naver?" params = {"ssc": "tab.blog.all", "sm": "tab_jum", "query": query} url = base_url + "&".join(f"{key}={value}" for key, value in params.items()) return url def crawl_blog_content(url, session): try: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Referer": "https://search.naver.com/search.naver", } delay = random.uniform(1, 2) time.sleep(delay) response = session.get(url, headers=headers) if response.status_code != 200: return "" soup = BeautifulSoup(response.content, "html.parser") content = soup.find("div", attrs={'class': 'se-main-container'}) if content: return clean_text(content.get_text()) else: return "" except Exception as e: return "" def crawl_naver_search_results(url, session): try: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Referer": "https://search.naver.com/search.naver", } response = session.get(url, headers=headers) if response.status_code != 200: return [] soup = BeautifulSoup(response.content, "html.parser") results = [] count = 0 for li in soup.find_all("li", class_=re.compile("bx.*")): if count >= 10: break for div in li.find_all("div", class_="detail_box"): for div2 in div.find_all("div", class_="title_area"): title = div2.text.strip() for a in div2.find_all("a", href=True): link = a["href"] if "blog.naver" in link: link = link.replace("https://", "https://m.") results.append({"제목": title, "링크": link}) count += 1 if count >= 10: break if count >= 10: break if count >= 10: break return results except Exception as e: return [] def clean_text(text): text = re.sub(r'\s+', ' ', text).strip() return text def fetch_references(topic): search_url = generate_naver_search_url(topic) session = setup_session() if session is None: return ["세션 설정 실패"] * 3 results = crawl_naver_search_results(search_url, session) if len(results) < 3: return ["충분한 검색 결과를 찾지 못했습니다."] * 3 selected_results = random.sample(results, 3) references = [] for result in selected_results: content = crawl_blog_content(result['링크'], session) references.append(f"제목: {result['제목']}\n내용: {content}") return references def fetch_crawl_results(query): references = fetch_references(query) return references[0], references[1], references[2] def get_style_prompt(style="친근한"): prompts = { "친근한": """ [친근한 포스팅 스타일 가이드] 1. 톤과 어조 - 대화하듯 편안하고 친근한 말투 사용 2. 문장 및 어투 - 반드시 '해요체'로 작성, 절대 '습니다'체를 사용하지 말 것. - '~요'로 끝나도록 작성, '~다'로 끝나지 않게 하라 - 구어체 표현 사용 (예: "~했어요", "~인 것 같아요") 3. 용어 및 설명 방식 - 전문 용어 대신 쉬운 단어로 풀어서 설명 - 비유나 은유를 활용하여 복잡한 개념 설명 - 수사의문문 활용하여 독자와 소통하는 느낌 주기 4. 독자와의 상호작용 - 독자의 의견을 물어보는 질문 포함 - 댓글 달기를 독려하는 문구 사용 주의사항: 너무 가벼운 톤은 지양하고, 주제의 중요성을 해치지 않는 선에서 친근함 유지 (예시: 잇님들~ 오레오 코카콜라맛이새로 출시가 됐다는거 알고 계셨나요?!ㅎ 오레오 코카콜라맛은 어떤지 솔직평과구매정보, 가격, 칼로리 등에 대해 자세~ 히 적어보도록 할께요! 오레오를 좋아하는 아들에게간식으로 오레오 코카콜라맛을 줬더니맛있다고 좋아하더라구요. 콜라향이 나서 더 마음에 든다며ㅎ개인적으로는 별 ⭐️⭐️⭐️.요건 개인차가 있을거 같아요~) """, "일반": """ #일반적인 블로그 포스팅 스타일 가이드 1. 톤과 어조 - 중립적이고 객관적인 톤 유지 - 적절한 존댓말 사용 (예: "~합니다", "~입니다") 2. 내용 구조 및 전개 - 명확한 주제 제시로 시작 - 논리적인 순서로 정보 전개 - 주요 포인트를 강조하는 소제목 활용 - 적절한 길이의 단락으로 구성 3. 용어 및 설명 방식 - 일반적으로 이해하기 쉬운 용어 선택 - 필요시 간단한 설명 추가 - 객관적인 정보 제공에 중점 4. 텍스트 구조화 - 불릿 포인트나 번호 매기기를 활용하여 정보 구조화 - 중요한 정보는 굵은 글씨나 기울임꼴로 강조 5. 독자 상호작용 - 적절히 독자의 생각을 묻는 질문 포함 - 추가 정보를 찾을 수 있는 키워드 제시 6. 마무리 - 주요 내용 간단히 요약 - 추가 정보에 대한 안내 제공 주의사항: 너무 딱딱하거나 지루하지 않도록 균형 유지 """, "전문적인": """ #전문적인 블로그 포스팅 스타일 가이드 1. 톤과 구조 - 공식적이고 학술적인 톤 사용 - 객관적이고 분석적인 접근 유지 - 명확한 서론, 본론, 결론 구조 - 체계적인 논점 전개 - 세부 섹션을 위한 명확한 소제목 사용 2. 내용 구성 및 전개 - 복잡한 개념을 정확히 전달할 수 있는 문장 구조 사용 - 논리적 연결을 위한 전환어 활용 - 해당 분야의 전문 용어 적극 활용 (필요시 간략한 설명 제공) - 심층적인 분석과 비판적 사고 전개 - 다양한 관점 제시 및 비교 3. 데이터 및 근거 활용 - 통계, 연구 결과, 전문가 의견 등 신뢰할 수 있는 출처 인용 - 필요시 각주나 참고문헌 목록 포함 - 수치 데이터는 텍스트로 명확히 설명 4. 텍스트 구조화 - 논리적 구조를 강조하기 위해 번호 매기기 사용 - 핵심 개념이나 용어는 기울임꼴로 강조 - 긴 인용문은 들여쓰기로 구분 5. 마무리 - 핵심 논점 재강조 - 향후 연구 방향이나 실무적 함의 제시 주의사항: 전문성을 유지하되, 완전히 이해하기 어려운 수준은 지양 """ } return prompts.get(style, prompts["친근한"]) def remove_unwanted_phrases(text): unwanted_phrases = [ '여러분', '최근', '마지막으로', '결론적으로', '결국', '종합적으로', '따라서', '마무리', '끝으로', '요약' ] words = re.findall(r'\S+|\n', text) result_words = [word for word in words if not any(phrase in word for phrase in unwanted_phrases)] return ' '.join(result_words).replace(' \n ', '\n').replace(' \n', '\n').replace('\n ', '\n') def generate_blog_post(query, prompt_template, style="친근한"): try: # 목표 글자수 설정 (문자 수) target_char_length = 3000 max_attempts = 2 # 최대 시도 횟수 # 참고글 가져오기 references = fetch_references(query) ref1, ref2, ref3 = references # OpenAI API 설정 model_name = "gpt-4o-mini" temperature = 0.85 max_tokens = 15000 top_p = 0.9 frequency_penalty = 0.5 presence_penalty = 0.3 # 스타일 프롬프트 가져오기 style_prompt = get_style_prompt(style) # 초기 프롬프트 구성 initial_prompt = f""" {prompt_template} {style_prompt} 주제: {query} 참고글 1: {ref1} 참고글 2: {ref2} 참고글 3: {ref3} 목표 글자수: {target_char_length} """ # 첫 번째 시도 messages = [{"role": "user", "content": initial_prompt}] response = openai.ChatCompletion.create( model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens, top_p=top_p, frequency_penalty=frequency_penalty, presence_penalty=presence_penalty, ) first_attempt = response['choices'][0]['message']['content'].strip() # 불필요한 표현 제거 및 글자수 확인 first_attempt_cleaned = remove_unwanted_phrases(first_attempt) first_attempt_length = len(first_attempt_cleaned) # 첫 번째 시도에서 목표 글자수 충족 시 if first_attempt_length >= target_char_length: final_post = f"주제: {query}\n\n{first_attempt_cleaned}" return final_post, ref1, ref2, ref3, first_attempt_length # 가장 긴 참고글 선택 longest_ref = max([ref1, ref2, ref3], key=len) # 두 번째 시도 (퇴고)를 위한 추가 프롬프트 revision_prompt = f""" 이전에 생성된 글을 기반으로 다음 지침을 반드시 따라서 글을 퇴고(revision)하라: 1. 반드시 이전 글의 구조와 내용을 유지하면서 참고글의 내용으로만 보완하라 2. 반드시 목표 글자수({target_char_length}자)를 충족하도록 내용을 보완하라 - 절대 글 하단부에 단순 첨가식 글자수 늘리기 금지 - 절대 글 마무리에 의미없는 감정적인 내용 추가 금지 - 반드시 이전 글의 본론부에 자연스럽게 보완하라 3. 반드시 마크다운 형식이 아닌 순수한 텍스트로만 출력하세요. 4. 반드시 이 표현들은 사용하지 마세요: 여러분, 최근, 마지막으로, 결론적으로, 결국, 종합적으로, 따라서, 마무리, 요약. 5. 글의 흐름을 자연스럽게 만들고, 각 단락 간의 연결을 부드럽게 해주세요. 6. 선택된 포스팅 스타일을 반드시 적용하세요. 이전 글: {first_attempt_cleaned} 참고글: {longest_ref} 포스팅 스타일: {style_prompt} """ # 두 번째 시도 (퇴고) messages = [{"role": "user", "content": revision_prompt}] response = openai.ChatCompletion.create( model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens, top_p=top_p, frequency_penalty=frequency_penalty, presence_penalty=presence_penalty, ) revised_attempt = response['choices'][0]['message']['content'].strip() # 불필요한 표현 제거 final_post = remove_unwanted_phrases(revised_attempt) # 최종 결과물 구성 final_post = f"주제: {query}\n\n{final_post}" actual_char_length = len(final_post) return final_post, ref1, ref2, ref3, actual_char_length except Exception as e: return f"블로그 글 생성 중 오류 발생: {str(e)}", "", "", "", 0 # PDF 클래스 및 관련 함수 정의 class PDF(FPDF2): def __init__(self): super().__init__() current_dir = os.path.dirname(__file__) self.add_font("NanumGothic", "", os.path.join(current_dir, "NanumGothic.ttf")) self.add_font("NanumGothic", "B", os.path.join(current_dir, "NanumGothicBold.ttf")) self.add_font("NanumGothicExtraBold", "", os.path.join(current_dir, "NanumGothicExtraBold.ttf")) self.add_font("NanumGothicLight", "", os.path.join(current_dir, "NanumGothicLight.ttf")) def header(self): self.set_font('NanumGothic', '', 10) def footer(self): self.set_y(-15) self.set_font('NanumGothic', '', 8) self.cell(0, 10, f'Page {self.page_no()}', 0, 0, 'C') def save_to_pdf(blog_post, user_topic): pdf = PDF() pdf.add_page() lines = blog_post.split('\n') title = lines[0].strip() content = '\n'.join(lines[1:]).strip() # 현재 날짜와 시간을 가져옵니다 (대한민국 시간 기준) now = datetime.now(ZoneInfo("Asia/Seoul")) date_str = now.strftime("%y%m%d") time_str = now.strftime("%H%M") # 파일명 생성 filename = f"{date_str}_{time_str}_{format_filename(user_topic)}.pdf" pdf.set_font("NanumGothic", 'B', size=14) pdf.cell(0, 10, title, ln=True, align='C') pdf.ln(10) pdf.set_font("NanumGothic", '', size=11) pdf.multi_cell(0, 5, content) print(f"Saving PDF as: {filename}") pdf.output(filename) return filename def format_filename(text): text = re.sub(r'[^\w\s-]', '', text) return text[:50].strip() def save_content_to_pdf(blog_post, user_topic): return save_to_pdf(blog_post, user_topic) # 기본 프롬프트 템플릿 DEFAULT_PROMPT_TEMPLATE = """ [블로그 글 작성 기본 규칙] 1. 반드시 한글로 작성하라 2. 주어진 참고글을 바탕으로 여행 블로그를 작성 3. 글의 제목을 여행 블로그 형태에 맞는 적절한 제목으로 출력 - 참고글의 제목도 참고하되, 동일하게 작성하지 말 것 4. 반드시 마크다운 형식이 아닌 순수한 텍스트로만 출력하라 5. 반드시 3000자 이상 작성하라 6. 주제와 참고글을 보고 여행 스타일(뚜벅이, 가족(아이, 부모님), 커플, 솔로 등)을 한가지 선정하여 작성하라 7. 어투는 참고글의 어투를 반영하되 여행에 대한 설레임이 담긴 어투를 사용하라 * 모든 내용들은 섹션을 구분하지 말고 자연스럽게 어우러지게 작성하라 [여행 글 작성 세부 규칙] 1. 사용자가 입력한 주제와 주어진 참고글을 바탕으로 여행 블로그 글 1개를 작성하라 2. 글의 주제는 입력된 주제와 참고글에 맞게 다양한 형태로 글을 작성하라 - 코스, 일정등의 형태(2박3일 여행 코스, 데이트 코스 등) - 큐레이션 형태(여행지 추천 Best5 등, 단 여행지는 최대 5곳) - 맞춤형 여행지 추천(커플, 데이트, 가족여행, 아이와 함께하는 여행, 부모님과 여행 등) - 단순 여행지 나열 금지 - 일정이나 코스에 따른 섹션 구분 금지 3. 독자가 직접 체험하는 것처럼 생생하게 전달하라 4. 개인적인 경험과 정보 제공의 균형을 맞춰, 독자들이 정보를 얻을 수 있도록 작성 5. 여행의 주요 활동(관광, 체험, 맛집 탐방 등)을 작성 6. 각 활동에서 겪은 개인적인 경험(대기 시간, 교통, 날씨 등)을 구체적으로 설명하라 7. 여행 중 먹은 음식이나 체험을 중심으로, 경험과 느낌등을 추가하고 구체적인 정보(메뉴, 가격, 위치 등)를 작성 8. 여행과 활동에 대한 각종 정보를 포함하라 [여행과 관련된 각종 정보] 1. 입장료, 준비물, 시간, 주차, 교통수단, 행사, 일정, 가격, 맛집정보, 꿀팁, 숙소 선택 기준, 주변 환경 등 2. 계절별로 달라지는 관광지의 모습, 즐길 거리, 주의사항 등 3. 여행지의 대표적인 특산물의 유래와 맛의 특징 4. 여행 전 준비 과정, 예약 팁, 필수 준비물 등 5. 인스타그램이나 SNS에 올리기 좋은 장소나 포토 스팟 등 6. 현지인들이 자주 가는 숨은 맛집이나 명소 7. 대중교통, 렌터카 등 이동 수단에 따른 여행 팁 8. 여행 중 겪을 수 있는 어려움(예: 웨이팅, 날씨 변화)과 대처 방법 등 9. 여행지의 역사나 문화적 배경을 간단히 소개 [반드시 제외해야 할 표현] 1. 반드시 참고글의 포함된 링크(URL)는 제외 2. 참고글에서 '링크를 확인해주세요'와 같은 링크 이동의 문구는 제외 3. 참고글에 있는 작성자, 화자, 유튜버, 기자(Writer, speaker, YouTuber, reporter)의 이름, 애칭, 닉네임(Name, Nkickname)은 반드시 제외 4. '업체로 부터 제공 받아서 작성', '쿠팡 파트너스'등의 표현을 반드시 제외하라. """ # Gradio 앱 생성 with gr.Blocks() as iface: gr.Markdown("# 블로그 글 작성기_여행(코스형) 블로그") gr.Markdown("주제를 입력하고 블로그 글 생성 버튼을 누르면 자동으로 블로그 글을 생성합니다.") query_input = gr.Textbox(lines=1, placeholder="키워드를 입력해주세요...", label="키워드") style_input = gr.Radio(["친근한", "일반", "전문적인"], label="포스팅 스타일", value="친근한") prompt_input = gr.Textbox(lines=10, value=DEFAULT_PROMPT_TEMPLATE, label="프롬프트 템플릿", visible=False) generate_button = gr.Button("블로그 글 생성") output_text = gr.Textbox(label="생성된 블로그 글") ref1_text = gr.Textbox(label="참고글 1", lines=10, visible=False) ref2_text = gr.Textbox(label="참고글 2", lines=10, visible=False) ref3_text = gr.Textbox(label="참고글 3", lines=10, visible=False) save_pdf_button = gr.Button("PDF로 저장") pdf_output = gr.File(label="생성된 PDF 파일") generate_button.click( generate_blog_post, inputs=[query_input, prompt_input, style_input], outputs=[output_text, ref1_text, ref2_text, ref3_text], show_progress=True ) save_pdf_button.click( save_content_to_pdf, inputs=[output_text, query_input], outputs=[pdf_output], show_progress=True ) # Gradio 앱 실행 if __name__ == "__main__": iface.launch()