Spaces:
Running
Running
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() |