import os import gradio as gr from bs4 import BeautifulSoup from datetime import datetime from zoneinfo import ZoneInfo import tempfile import requests import re import logging from PIL import Image from urllib.request import urlopen import io # google-genai 라이브러리 임포트 from google import genai # 로깅 설정 logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) # 환경변수를 통해 Gemini API 키를 가져옴 API_KEY = os.getenv("GEMINI_API_KEY") if API_KEY is None: raise ValueError("GENAI_API_KEY 환경 변수가 설정되지 않았습니다.") client = genai.Client(api_key=API_KEY) # Gemini API를 호출하는 함수 (Gemini는 max_tokens, temperature, top_p 파라미터 없이 프롬프트를 결합하여 요청) def call_api(content, system_message, max_tokens=None, temperature=None, top_p=None): try: prompt = system_message + "\n" + content response = client.models.generate_content( model="gemini-2.0-flash", contents=prompt ) return response.text.strip() except Exception as e: logger.error(f"API 호출 오류: {str(e)}") return f"Gemini API Error: {str(e)}" def analyze_info(data): return (f"선택한 카테고리: {data['category']}\n" f"선택한 포스팅 스타일: {data['style']}\n" f"참고 글1: {data['references1']}\n" f"참고 글2: {data['references2']}\n" f"참고 글3: {data['references3']}\n") def generate_outline(category, style, references1, references2, references3, photo_recommendations): data = { 'category': category, 'style': style, 'references1': references1, 'references2': references2, 'references3': references3, 'photo_recommendations': photo_recommendations } full_content = analyze_info(data) logger.info(f"아웃라인 생성을 위한 전체 내용: {full_content}") system_prompt = get_outline_prompt(data['category']) + "\n\n" + get_style_prompt(data['style']) user_prompt = f"{full_content}\n\n사진 키워드: {photo_recommendations}" modified_text = call_api(user_prompt, system_prompt, 2000, 0.7, 0.95) # API 오류 확인 if modified_text is None or modified_text.startswith("Gemini API Error"): logger.error(f"아웃라인 생성 중 API 오류: {modified_text}") raise Exception(f"아웃라인 생성 API 오류: {modified_text}") # 불필요한 빈 줄 제거: 연속되는 개행 문자를 단일 개행으로 변경 modified_text = re.sub(r'\n\s*\n', '\n', modified_text) logger.info(f"Generated outline: {modified_text}") return modified_text 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 format_sentences(text): """ 긴 문장을 3~6개 단어 단위로 분리하는 함수 (한국어에 최적화) """ if text is None: return "" # text가 None인 경우 빈 문자열 반환 # 문장 단위로 분리 sentences = re.split(r'(?<=[.!?])\s+', text) formatted_sentences = [] for sentence in sentences: # 문장이 충분히 길 경우만 분리 처리 if len(sentence) > 20: # 더 짧은 문장은 그대로 유지 # 띄어쓰기를 기준으로 단어 분리 words = sentence.split() if len(words) <= 4: # 이미 6단어 이하면 그대로 사용 formatted_sentences.append(sentence) continue chunk = [] word_count = 0 for word in words: chunk.append(word) word_count += 1 # 한글은 영어보다 한 단어가 더 길기 때문에, 단어 수를 4~7개로 조정 if word_count >= 4 and (word_count >= 7 or re.search(r'[,;:]$', word)): formatted_sentences.append(' '.join(chunk)) chunk = [] word_count = 0 # 남은 단어들 처리 if chunk: formatted_sentences.append(' '.join(chunk)) else: formatted_sentences.append(sentence) return "\n".join(formatted_sentences) def extract_keywords(text, top_n=5): from sklearn.feature_extraction.text import CountVectorizer vectorizer = CountVectorizer(stop_words='english', ngram_range=(1,2)) count_matrix = vectorizer.fit_transform([text]) terms = vectorizer.get_feature_names_out() counts = count_matrix.sum(axis=0).A1 term_counts = sorted(zip(terms, counts), key=lambda x: x[1], reverse=True) return [term for term, count in term_counts[:top_n]] def get_outline_prompt(category): if category == "방문후기형": return """ ## 시스템 역할 당신은 수년간의 경험을 가진 전문 방문 리뷰 블로거입니다. 맛집, 카페, 숙소, 관광지 등 다양한 장소에 대한 생생한 리뷰로 많은 독자들의 신뢰를 받고 있습니다. ## 분석 단계 1. 참고글 3개를 철저히 분석하여 핵심 주제와 중요 정보 파악 2. 방문한 장소의 유형과 특성 식별 (식당, 카페, 숙소, 관광지 등) 3. 리뷰의 핵심이 될 5가지 주요 요소 파악 (분위기, 맛, 서비스, 가격, 특별함 등) ## 아웃라인 구성 원칙 1. 도입부(1개) - 호기심을 자극하는 제목으로 시작 2. 본론(4-5개) - 참고글 분석을 통해 발견한 장소/경험의 핵심 가치와 특징을 담은 소제목 * 참고글에서 가장 강조되는 특징이나 장점 * 방문자들이 가장 관심을 가질 만한 요소 * 차별화된 경험이나 독특한 특성 * 실용적인 정보나 알아두면 좋은 팁 * (위 항목들은 예시일 뿐, 참고글 분석을 통해 자유롭게 결정) 3. 결론(1개) - 전체 경험을 요약하는 매력적인 제목 ## 핵심 지침 - **완전히 한국어로만 작성**할 것 - 소제목은 **최대 30자 이내**로 간결하게 작성 - 호기심을 자극하는 표현 사용 (예: "꼭 알아야 할", "놀라운", "숨겨진") - 장소의 가장 매력적인 포인트가 소제목에 반영되도록 구성 - **사진 키워드는 소제목 결정에 영향을 주지 않음** (본문 작성 시 참고사항으로만 활용) - 전체 아웃라인은 도입부(1) + 본론(최대 5개) + 결론(1)으로 구성 ## 출력 형식 * 참고글 분석을 통해 가장 핵심적인 주제와 특징을 파악하여 자유롭게 아웃라인 구성 * 하지만 반드시 다음 구조를 유지할 것:(각 항목당 1번엔터를 적용하요 빈칸이 나오지 않도록하라.) - 도입부: 1개 (호기심을 자극하는 흥미로운 제목) - 본론: 4-5개 (장소/경험의 가장 중요한 특징을 반영한 제목) - 결론: 1개 (전체 경험 요약 제목) * 소제목은 장소의 실제 경험과 특징에 맞게 자유롭게 구성 * 사진 키워드에 맞추지 말고, 참고글 분석을 통해 발견한 핵심 가치와 특징 기반으로 구성 예시 형식 (참고용일 뿐, 내용은 참고글에 따라 완전히 달라질 수 있음): 도입부: [흥미로운 도입 제목] 본론1: [핵심 특징/장점 관련 제목] 본론2: [또 다른 주요 특징 관련 제목] 본론3: [차별화 요소 관련 제목] 본론4: [유용한 정보/팁 관련 제목] 본론5: [추가 특징/정보 관련 제목] (필요시) 결론: [전체 경험 요약 제목] """ def get_blog_post_prompt(category): if category == "방문후기형": return """ # 방문후기형 블로그 콘텐츠 생성 시스템 [v2.0] ## 시스템 역할 당신은 수만 명의 팔로워를 보유한 인기 방문 리뷰 블로거입니다. 생생한 현장감과 디테일한 정보 제공으로 독자들에게 실제 방문한 듯한 경험을 전달하는 전문가입니다. ## 콘텐츠 생성 원칙 ### 1. 글의 구조 - **도입부** (전체 글의 10-15%): * 장소 소개 및 방문 계기 * 첫인상과 기대감 표현 * 위치정보와 함께 "[지도 정보를 넣어주세요]" 명시 * 길이: 500자 이상 - **본론** (전체 글의 70-80%): * 아웃라인의 각 소제목에 맞춘 상세 내용 * 장소의 분위기, 제품/서비스 품질, 가격, 서비스 등 핵심 요소 상세 설명 * 실제 경험 기반의 생생한 표현 * 각 섹션은 400-600자 수준으로 균형있게 작성 * 길이: 2500자 이상 - **결론** (전체 글의 10-15%): * 전체 경험 요약 및 추천 이유 * 추가 팁이나 방문 시 참고사항 * 길이: 400자 이상 ### 2. 사진 삽입 지침 - 사진 키워드는 **내용에 맞게** 본문 중간에 자연스럽게 삽입 - 사진 키워드는 내용과 관련 있을 때만 사용하고, 억지로 모든 키워드를 사용하지 않음 - 사진 키워드는 대괄호로 표시: [키워드] - 사진 키워드 앞뒤로 빈 줄 삽입 - 사진 키워드는 소제목이나 내용 구성에 영향을 주지 않으며, 글의 자연스러운 흐름을 따라 적절한 위치에만 삽입 - 글의 내용과 맞지 않는 사진 키워드는 과감히 생략 - 사진 키워드가 없거나 사용하지 않아도 글의 퀄리티나 완성도에는 전혀 영향 없음 ### 3. 글쓰기 스타일 - 한 문장을 2-3 부분으로 나눠서 줄바꿈하여 가독성 향상 - 소제목은 볼드체로 구분하고 전후에 빈 줄 삽입 - 객관적 사실과 주관적 경험을 균형있게 혼합 - 핵심 장점과 단점을 솔직하게 표현 - 과장된 표현보다 구체적인 디테일로 신뢰감 형성 - 각 문단 사이에 적절한 빈 줄을 삽입하여 가독성 높임 - 긴 문단은 2-3개의 작은 문단으로 나누어 읽기 편하게 구성 ### 4. 콘텐츠 품질 기준 - 실제 경험한 듯한 디테일한 묘사 - 모든 감각(시각, 청각, 미각, 후각, 촉각)을 활용한 표현 - 가격, 영업시간, 특별 혜택 등 실용적 정보 포함 - 특별한 팁이나 알면 유용한 정보 제공 - 전체 길이: 최소 3000자자이상 ### 5.출력 형식 요구사항 - 소제목은 볼드체로 표시하고 전후에 빈 줄 삽입 - 모든 텍스트는 가운데 정렬 - 문장은 2-3개 단위로 줄바꿈하여 가독성 확보 - 각 문단 사이에는 반드시 빈 줄 삽입 - 사진 키워드는 필요한 위치에만 대괄호로 표시하고 전후에 빈 줄 삽입 - 모든 단락은 적절한 길이로 균형있게 구성 - 특히 중요한 내용이나 강조하고 싶은 부분은 문장 단위로 줄바꿈하여 시각적 임팩트 강화 - 참고글의 내용을 토대로 새롭게 구성하되, 다음을 반드시 준수하라: - 참고글에 언급된 닉네임, 이름, 회사명, 브랜드명 등을 그대로 사용하지 말고 다른 명칭으로 변경하라. - 쿠팡파트너스, 광고, 제품 협찬, 소정의 금액이나 사은품을 받았다는 내용 제외할 것 - 참고글 작성자의 경험이 아닌 나의 직접 경험으로 재구성할 것 """ def get_style_prompt(style): prompts = { "친근한": """ # 친근한 블로그 글쓰기 스타일 프로필 ## 톤 & 보이스 - 독자와 대화하는 듯한 친근하고 편안한 어투 - '~해요', '~네요', '~인 것 같아요' 등 구어체 표현 적극 활용 - 감정과 느낌을 솔직하게 표현하는 개인적인 어조 - 감탄사를 적절히 활용 (예: 와~, 정말!, ㅎㅎ) - 이모티콘은 사용하지마라 ## 문체 & 문법 - **해요체 전용**: 모든 문장은 '~합니다'가 아닌 '~해요'로 종결 - 짧고 간결한 문장 구조로 읽기 쉽게 구성 - 질문형 문장으로 독자의 공감 유도 (예: "여러분도 그렇지 않나요?") - 개인적 경험을 자연스럽게 공유하는 1인칭 시점 ## 어휘 & 표현 - 일상적이고 쉬운 단어 선택 (전문용어 사용 시 풀어서 설명) - 직관적인 비유와 예시로 설명 (예: "마치 구름 위를 걷는 듯한 느낌이었어요") - 생생한 감각적 표현으로 현장감 전달 - 과장된 표현보다는 솔직한 느낌 중심의 서술 ## 독자와의 관계 - 독자를 '여러분'으로 지칭하며 친근감 형성 - 독자의 입장을 고려한 공감대 형성 표현 사용 - 독자에게 직접 말을 거는 듯한 대화형 문체 - 정보 전달과 함께 개인적인 팁이나 조언 제공 ## 예시 문장 "여기 분위기가 정말 좋더라고요. 은은한 조명과 차분한 음악이 마음을 편안하게 해줬어요. 가격은 조금 있는 편이지만 그만큼 가치 있는 경험이었답니다. 다음에 방문하실 때는 평일 오후가 한적해서 추천해요!" """, "일반": """ # 균형 잡힌 블로그 글쓰기 스타일 프로필 ## 톤 & 보이스 - 객관적 정보와 주관적 의견이 균형을 이룬 중립적 어조 - 정중하고 예의 바른 어투로 신뢰감 형성 - 과장된 표현이나 감정 표현 자제 - 명확하고 간결한 문장으로 핵심 정보 전달 ## 문체 & 문법 - '합니다/습니다' 종결어미 사용으로 단정한 인상 - 논리적 구조와 명확한 흐름을 가진 문장 구성 - 문법적으로 올바르고 정제된 표현 사용 - 적절한 길이의 단락으로 가독성 확보 ## 어휘 & 표현 - 일반 독자가 이해할 수 있는 수준의 어휘 선택 - 전문 용어 사용 시 간략한 설명 제공 - 구체적인 수치와 사실 중심의 설명 - 비교와 대조를 통한 명확한 정보 전달 ## 독자와의 관계 - 적절한 거리감 유지로 신뢰성 확보 - 독자를 위한 유용한 정보 중심 구성 - 직접적인 추천이나 의견 제시 시 근거 함께 제공 - 독자가 스스로 판단할 수 있는 객관적 정보 제공 ## 예시 문장 "이 레스토랑은 2020년에 오픈한 모던 이탈리안 다이닝입니다. 내부는 50석 규모로 구성되어 있으며 프라이빗한 공간도 마련되어 있습니다. 가격대는 1인당 3만원에서 5만원 선으로 다른 이탈리안 레스토랑과 비교했을 때 중상위권에 속합니다. 특히 자체 제작하는 파스타 면이 이 곳의 차별점입니다." """, "전문적인": """ # 전문가형 블로그 글쓰기 스타일 프로필 ## 톤 & 보이스 - 깊이 있는 지식과 경험이 느껴지는 전문적 어조 - 객관적 사실과 분석적 관점이 돋보이는 논리적 서술 - 권위 있고 설득력 있는 어투로 신뢰감 형성 - 정제된 표현과 체계적인 구성으로 전문성 강조 ## 문체 & 문법 - 정확하고 간결한 문장 구조 - 복잡한 개념도 명확히 전달하는 논리적 흐름 - 전문 용어의 적절한 활용과 설명 - 학술적 글쓰기에 가까운 체계적 문단 구성 ## 어휘 & 표현 - 해당 분야의 전문 용어와 개념 적극 활용 - 구체적인 수치와 데이터 기반 설명 - 비교 분석과 평가를 위한 전문적 기준 제시 - 정확한 인용과 참조를 통한 신뢰성 확보 ## 독자와의 관계 - 전문가로서 지식과 통찰력 공유 - 객관적 평가와 전문적 조언 제공 - 독자의 지적 호기심을 자극하는 심층 분석 - 업계 트렌드나 전문적 관점에서의 평가 제시 ## 예시 문장 "본 레스토랑은 미쉐린 출신 셰프가 이탈리안 퀴진의 정수를 선보이는 공간입니다. 특히 72시간 저온 숙성한 도우를 화덕에서 90초간 구워내는 나폴리 방식의 피자는 국내 최고 수준으로 평가받고 있습니다. 식재료는 100% 유기농 인증을 받은 지역 농가에서 직송되며, 와인 페어링을 위한 소믈리에의 전문적인 큐레이션이 식사 경험을 한층 더 풍부하게 합니다." """ } return prompts.get(style, "포스팅 스타일 프롬프트") def generate_blog_post(category, style, references1, references2, references3, outline, photo_recommendations): try: logger.info("1. 데이터 준비") data = { 'category': category, 'style': style, 'references1': references1, 'references2': references2, 'references3': references3, 'outline': outline, 'photo_recommendations': photo_recommendations } logger.info("2. 프롬프트 준비") system_prompt = get_blog_post_prompt(data['category']) style_prompt = get_style_prompt(data['style']) # 수정된 유저 프롬프트 - 각 줄당 4~7단어 지정 user_prompt = f""" **반드시 3000자 이상 작성하라** 참고글1: {data['references1']} 참고글2: {data['references2']} 참고글3: {data['references3']} 아웃라인: {data['outline']} 사진 키워드: {data['photo_recommendations']} 글 작성 형식 규칙: 1. 각 문장은 4~7단어 단위로 줄바꿈하여 작성할 것 2. 사진 키워드는 [키워드] 형태로 앞뒤에 빈 줄을 추가할 것 3. 소제목은 볼드체로 표시하고 전후에 빈 줄 삽입할 것 4. 모든 텍스트는 가운데 정렬로 작성할 것 5. 문단 사이에는 반드시 빈 줄을 넣을 것 """ logger.info("3. 글 생성 시작") full_post = call_api( user_prompt, system_prompt + "\n" + style_prompt, max_tokens=15000, temperature=0.7, top_p=0.95 ) # API 호출 응답 확인 if full_post is None or full_post.startswith("Gemini API Error"): error_msg = "API 응답 없음" if full_post is None else full_post logger.error(f"블로그 글 생성 API 오류: {error_msg}") return f"

글 생성 중 오류가 발생했습니다: {error_msg}

" logger.info(f"Gemini가 생성한 원본 글 길이: {len(full_post)}") logger.info("4. 불필요한 문구 제거 및 텍스트 형식 조정") # 불필요한 문구 제거 filtered_post = remove_unwanted_phrases(full_post).lstrip() # 필요한 경우 문장을 4~7단어 단위로 추가 포맷팅 formatted_post = format_sentences(filtered_post) # 사진 키워드 패턴 앞뒤로 빈 줄 추가 pattern = r'(\[[\w\s가-힣]+\])' processed_text = re.sub(pattern, r'\n\n\1\n\n', formatted_post) # 중복된 빈 줄 정리 processed_text = re.sub(r'\n{3,}', '\n\n', processed_text) logger.info("5. HTML 변환") html_post = convert_to_html(processed_text) logger.info("6. 최종 결과 반환") return html_post except Exception as e: logger.error(f"글 생성 중 오류 발생: {str(e)}") return f"

글 생성 중 오류 발생: {str(e)}

" def convert_to_html(text): if text is None: return "

텍스트 변환 오류가 발생했습니다.

" lines = text.split('\n') html_lines = [] in_paragraph = False for i, line in enumerate(lines): line = line.strip() # 빈 줄 처리 if not line: if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append("
") continue # 사진 키워드 처리 (대괄호로 된 텍스트) if re.match(r'^\[[\w\s가-힣]+\]$', line): if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append("
") html_lines.append(f"

{line}

") html_lines.append("
") continue # 헤더 처리 if line.startswith('####'): if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append(f"

{line[4:].strip()}

") elif line.startswith('###'): if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append(f"

{line[3:].strip()}

") elif line.startswith('##'): if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append(f"

{line[2:].strip()}

") elif line.startswith('#'): if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append(f"

{line[1:].strip()}

") # 리스트 아이템 처리 elif line.startswith('- '): if in_paragraph: html_lines.append("

") in_paragraph = False html_lines.append(f"
  • {line[2:]}
  • ") # 일반 텍스트 처리 - 단락 형식 유지 else: # 볼드체 처리 line = re.sub(r'\*\*(.*?)\*\*', r'\1', line) # 소제목 처리 (볼드체로 시작하는 경우) if line.startswith('') and line.endswith(''): if in_paragraph: html_lines.append("

    ") in_paragraph = False html_lines.append(f"

    {line}

    ") else: # 일반 텍스트는 단락으로 처리 if not in_paragraph: html_lines.append("

    ") in_paragraph = True html_lines.append(f"{line}
    ") # 마지막 단락 닫기 if in_paragraph: html_lines.append("

    ") html_content = f"""
    {"".join(html_lines)}
    """ return html_content # API 함수들 def generate_outline_5(category, style, ref1, ref2, ref3, photo_recommendations): return generate_outline(category, style, ref1, ref2, ref3, photo_recommendations) def generate_blog_post_5(category, style, ref1, ref2, ref3, outline, photo_recommendations): return generate_blog_post(category, style, ref1, ref2, ref3, outline, photo_recommendations)