pftyom_2-1 / app.py
magictreee's picture
Update app.py
74793db verified
import os
import random
import time
import re
import json
import requests
from bs4 import BeautifulSoup
from datetime import datetime
from zoneinfo import ZoneInfo
import sys
import logging
import html
from fpdf import FPDF as FPDF2
import openai
import gradio as gr
from PIL import Image
from urllib.request import urlopen
import tempfile
import markdown2 # 마크다운 변환을 위한 라이브러리 추가 설치 필요
# Pretendard OTF 폰트 파일 경로 설정
FONT_REGULAR_PATH = os.path.join("Pretendard-Regular.otf")
FONT_BOLD_PATH = os.path.join("Pretendard-Bold.otf")
# API 설정
API_BASE_URL = os.getenv("API_BASE_URL", "").rstrip('/')
API_KEY = os.getenv("API_KEY")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
# OpenAI 설정
openai.api_key = OPENAI_API_KEY
# API 헤더 설정
API_HEADERS = {
"x-api-key": API_KEY,
"content-type": "application/json"
}
def fetch_references(topic):
"""API를 통해 참고 블로그 글 가져오기"""
try:
if not topic or not topic.strip():
return ["검색 키워드를 입력해주세요."] * 3
# URL 인코딩된 키워드를 사용하여 API 호출
encoded_keyword = requests.utils.quote(topic.strip())
url = f"{API_BASE_URL}/search/{encoded_keyword}"
response = requests.get(url, headers=API_HEADERS)
if response.ok:
result = response.json()
return [
result.get("reference1", "참고글1을 찾을 수 없습니다."),
result.get("reference2", "참고글2를 찾을 수 없습니다."),
result.get("reference3", "참고글3를 찾을 수 없습니다.")
]
else:
return [f"API 오류: {response.text}"] * 3
except Exception as e:
return [f"참고글 수집 중 오류 발생: {str(e)}"] * 3
def fetch_crawl_results(query):
"""API를 통해 블로그 검색 결과 가져오기"""
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 get_random_prompt():
prompts = [
"""
[블로그 글 작성 기본 규칙]
1. 반드시 한글로 작성하라
2. 주어진 참고글을 바탕으로 여행 블로그를 작성
3. 글의 주제는 반드시 주어진 참고글에서 1개 여행지 정보를 선정하여 작성하라
4. 글의 제목을 1개 여행지 블로그 형태에 맞는 적절한 제목으로 출력
- 참고글의 제목도 참고하되, 동일하게 작성하지 말 것
- [예시] : "제목: 작성할 실제 제목"을 가장 첫줄에 작성하라라
5. 반드시 마크다운 형식으로 출력하라
6. 어투는 참고글의 어투를 반영하되 여행에 대한 설레임이 담긴 어투를 사용하라
* 모든 내용들은 섹션을 구분하지 말고 자연스럽게 어우러지게 작성하라
[블로그 글 작성 세부 규칙]
1. 사용자가 입력한 주제와 주어진 참고글 3개를 바탕으로 여행 블로그 글 1개를 작성
- 반드시 1개 여행지에만 집중된 주제 선정
2. 주어진 모든 글을 분석하여 하나의 대주제를 선정하라(1개의 참고글에 치우치지 않도록 할 것)
3. 해당 여행지를 가장 잘 어필할 수 있는 정보(팁)와 경험을 반영(여행 타겟 선정)
4. 입력된 주제에 맞게 다양한 형태로 글을 작성하라 :
- 타겟 맞춤형 여행지(커플 데이트, 가족여행, 아이와 함께하는 여행, 부모님과 여행 등)
5. 객관적인 여행 정보와 주관적인 고객 반응을 균형있게 제시하라
6. 여행지와 관련된 팁, 각종 정보(시간, 주차, 주소(장소), 가격, 행사, 맛집(음식) 등)를 글에 상세히 녹여내라
- 추가 정보 및 팁의 형태로 글의 섹션을 별도로 구분하지 말고 여행 정보안에 적절히 반영
7. 어투는 주어진 참고글 3가지의 어투를 적절히 반영하되 여행에 대한 설레임을 반영하라
- 특히 문장의 끝 부분을 적절히 반영(가급적 '~요'로 끝나도록 작성)
- 너무 딱딱하지 않게 편안하게 읽을 수 있도록 자연스러운 대화체를 반영
- 단어 선택은 쉬운 한국어 어휘를 사용하고 사전식표현, 오래된 표현은 제외하라
8. 글의 도입부를 참고글의 실제 경험과 독자의 관심을 끄는 요소(질문, 흥미로운 사실, 통계, 공감대 형성, 문제제기 등)를 반영하여 다양하게 표현하라
[반드시 제외해야 할 표현]
1. 반드시 참고글의 포함된 링크(URL)는 제외
2. 참고글에서 '링크를 확인해주세요'와 같은 링크 이동의 문구는 제외
3. 참고글에 있는 작성자, 화자, 유튜버, 기자(Writer, speaker, YouTuber, reporter)의 이름, 애칭, 닉네임(Name, Nkickname)은 반드시 제외
4. '업체로 부터 제공 받아서 작성', '쿠팡 파트너스'등의 표현을 반드시 제외하라.
""",
"""
[블로그 글 작성 기본 규칙]
1. 반드시 한글로 작성하라
2. 주어진 참고글을 바탕으로 여행 블로그를 작성
3. 글의 제목을 여행 블로그 형태에 맞는 적절한 제목으로 출력
- 참고글의 제목도 참고하되, 동일하게 작성하지 말 것
- [예시] : "제목: 작성할 실제 제목"을 가장 첫줄에 작성하라라
5. 반드시 마크다운 형식으로 출력하라
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. '업체로 부터 제공 받아서 작성', '쿠팡 파트너스'등의 표현을 반드시 제외하라.
"""
]
return random.choice(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, style="친근한"):
try:
# 랜덤으로 프롬프트 선택
prompt_template = get_random_prompt()
# 목표 글자수 설정 (문자 수)
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"""
주제: {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}"
# convert_to_html 함수 사용
html_post = convert_to_html(final_post)
return html_post, ref1, ref2, ref3, first_attempt_length
# 가장 긴 참고글 선택
longest_ref = max([ref1, ref2, ref3], key=len)
# 두 번째 시도 (퇴고)를 위한 추가 프롬프트
revision_prompt = f"""
이전 글:
{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)
# HTML 포매팅 함수
def format_blog_post(blog_post):
lines = blog_post.split('\n')
formatted_lines = []
for line in lines:
line = line.strip()
if line.startswith('####'):
formatted_lines.append(f"<h4>{html.escape(line[4:].strip())}</h4>")
elif line.startswith('###'):
formatted_lines.append(f"<h3>{html.escape(line[3:].strip())}</h3>")
elif line.startswith('##'):
formatted_lines.append(f"<h2>{html.escape(line[2:].strip())}</h2>")
elif line.startswith('#'):
formatted_lines.append(f"<h1>{html.escape(line[1:].strip())}</h1>")
elif line.startswith('- '):
content = html.escape(line[2:])
bold_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', content)
formatted_lines.append(f"<li>{bold_content}</li>")
elif line:
content = html.escape(line)
bold_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', content)
formatted_lines.append(f"<p>{bold_content}</p>")
else:
formatted_lines.append("<br>")
return '\n'.join(formatted_lines)
# 글 생성 후 HTML 포매팅
final_post = format_blog_post(final_post)
# HTML로 변환
first_line, remaining_post = final_post.split("\n", 1) # 첫 줄과 나머지 본문 분리
if first_line.startswith("제목:"):
h1_title = first_line.replace("제목:", "").strip() # 제목 부분에서 "제목:" 제거
html_post = f"""<div class="blog-post">
<h1>{html.escape(h1_title)}</h1>
{remaining_post}
</div>"""
else:
html_post = f"""<div class="blog-post">
{final_post}
</div>"""
# 실제 글자 수 계산 (HTML 태그 제외)
actual_char_length = len(BeautifulSoup(html_post, 'html.parser').get_text())
return html_post, ref1, ref2, ref3, actual_char_length
except Exception as e:
return f"<p>블로그 글 생성 중 오류 발생: {str(e)}</p>", "", "", "", 0
# PDF 클래스 정의
class PDF(FPDF2):
def __init__(self):
super().__init__()
self.add_font("Pretendard", "", FONT_REGULAR_PATH, uni=True)
self.add_font("Pretendard", "B", FONT_BOLD_PATH, uni=True)
def header(self):
self.set_font("Pretendard", "", 10)
def footer(self):
self.set_y(-15)
self.set_font("Pretendard", "", 8)
self.cell(0, 10, f'Page {self.page_no()}', align='C')
def save_to_pdf(blog_post, user_topic): # 인수 두 개 받도록 수정
pdf = PDF()
pdf.add_page()
# HTML에서 텍스트 추출
soup = BeautifulSoup(blog_post, 'html.parser')
title = soup.h1.text.strip() if soup.h1 else "블로그 글"
# 페이지 및 이미지 크기 설정
page_width = pdf.w - 2 * pdf.l_margin
image_width = page_width
# 상단 이미지 경로와 링크 설정
image_url1 = "https://finalendai.com/wp-content/uploads/2024/10/pdf-banner-top.png"
image_url2 = "https://finalendai.com/wp-content/uploads/2024/10/pdf-banner-bottom.png"
# 첫 번째 이미지 삽입 (상단)
with urlopen(image_url1) as response:
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_file:
tmp_file.write(response.read())
tmp_file_path = tmp_file.name
try:
img_width, img_height = Image.open(tmp_file_path).size
ratio = img_height / img_width
image_height = image_width * ratio
x = (pdf.w - image_width) / 2
y = pdf.get_y()
pdf.link(x, y, image_width, image_height, "https://finalendai.com")
pdf.image(tmp_file_path, x=x, y=y, w=image_width)
pdf.ln(image_height + 10)
finally:
os.unlink(tmp_file_path)
# 제목 출력 (한 번만)
pdf.set_font("Pretendard", "B", 16)
pdf.multi_cell(0, 10, title, align='C')
pdf.ln(10)
# 본문 내용 추가
pdf.set_font("Pretendard", "", 12)
for tag in soup.find_all(["h2", "h3", "p", "ul", "li"]):
if tag.name == "h2":
pdf.set_font("Pretendard", "B", 14)
pdf.multi_cell(0, 8, tag.get_text().strip())
pdf.ln(4)
elif tag.name == "h3":
pdf.set_font("Pretendard", "B", 12)
pdf.multi_cell(0, 6, tag.get_text().strip())
pdf.ln(3)
elif tag.name == "p":
pdf.set_font("Pretendard", "", 12)
pdf.multi_cell(0, 8, tag.get_text().strip())
pdf.ln(4)
elif tag.name == "ul" or tag.name == "li":
pdf.set_font("Pretendard", "", 12)
pdf.multi_cell(0, 8, f"• {tag.get_text().strip()}")
pdf.ln(4)
# 하단 이미지 삽입 (링크 포함)
with urlopen(image_url2) as response:
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_file:
tmp_file.write(response.read())
tmp_file_path = tmp_file.name
try:
if pdf.get_y() + image_height > pdf.page_break_trigger:
pdf.add_page()
x = (pdf.w - image_width) / 2
y = pdf.get_y()
pdf.link(x, y, image_width, image_height, "https://finalendai.com/story/")
pdf.image(tmp_file_path, x=x, y=y, w=image_width)
finally:
os.unlink(tmp_file_path)
# 파일 저장
now = datetime.now(ZoneInfo("Asia/Seoul"))
filename = f"{now.strftime('%y%m%d_%H%M')}_{format_filename(title)}.pdf"
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)
# Gradio 앱 생성
with gr.Blocks() as iface:
gr.Markdown("# 여행 블로그")
gr.Markdown("작성할 글의 주제를 입력해 주세요.")
query_input = gr.Textbox(lines=1, placeholder="안동국제탈춤페스티벌, 2024금산인삼축제, 계룡 군 문화축제, 대전 성심당, 10월 경주 여행 코스, 부산 아이와 함께 가볼만한 곳, 10월 제주도 핑크뮬리", label="키워드")
style_input = gr.Radio(["친근한", "일반적인", "전문적인"], label="포스팅 스타일", value="친근한")
generate_button = gr.Button("블로그 글 생성")
output_html = gr.HTML(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, style_input],
outputs=[output_html, ref1_text, ref2_text, ref3_text],
show_progress=True
)
save_pdf_button.click(
save_content_to_pdf,
inputs=[output_html, query_input],
outputs=[pdf_output],
show_progress=True
)
# Gradio 앱 실행
if __name__ == "__main__":
iface.launch()