diff --git a/apps/pre-processing-service/app/core/config.py b/apps/pre-processing-service/app/core/config.py index ed54cc69..69e29d35 100644 --- a/apps/pre-processing-service/app/core/config.py +++ b/apps/pre-processing-service/app/core/config.py @@ -80,6 +80,9 @@ class BaseSettingsConfig(BaseSettings): # MeCab 사전 경로 (자동 감지) mecab_path: Optional[str] = None + # 테스트/추가용 필드 + openai_api_key: Optional[str] = None # << 이 부분 추가 + def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/apps/pre-processing-service/app/service/keyword_service.py b/apps/pre-processing-service/app/service/keyword_service.py index 575767ee..f8065fa3 100644 --- a/apps/pre-processing-service/app/service/keyword_service.py +++ b/apps/pre-processing-service/app/service/keyword_service.py @@ -1,4 +1,3 @@ -# Pydantic 모델을 가져오기 위해 schemas 파일 import import json import random diff --git a/apps/pre-processing-service/app/service/product_blog_posting_service.py b/apps/pre-processing-service/app/service/product_blog_posting_service.py new file mode 100644 index 00000000..129c4666 --- /dev/null +++ b/apps/pre-processing-service/app/service/product_blog_posting_service.py @@ -0,0 +1,387 @@ +import json +import logging +import os +from datetime import datetime +from typing import Dict, List, Optional, Any +from dataclasses import dataclass +from enum import Enum + +from openai import OpenAI +from dotenv import load_dotenv + +from app.service.blog.blogger_blog_post_adapter import BloggerBlogPostAdapter +from app.errors.BlogPostingException import * + +# 환경변수 로드 +load_dotenv(".env.dev") + +client = OpenAI() + + +class PostingStatus(Enum): + PENDING = "pending" + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" + RETRY = "retry" + + +@dataclass +class ProductData: + """크롤링된 상품 데이터 모델""" + + tag: str + product_url: str + title: str + price: int + rating: float + options: List[Dict[str, Any]] + material_info: Dict[str, str] + product_images: List[str] + crawled_at: str + + @classmethod + def from_dict(cls, data: Dict) -> "ProductData": + """딕셔너리에서 ProductData 객체 생성""" + product_detail = data.get("product_detail", {}) + return cls( + tag=data.get("tag", ""), + product_url=product_detail.get("url", ""), + title=product_detail.get("title", ""), + price=product_detail.get("price", 0), + rating=product_detail.get("rating", 0.0), + options=product_detail.get("options", []), + material_info=product_detail.get("material_info", {}), + product_images=product_detail.get("product_images", []), + crawled_at=data.get("crawled_at", ""), + ) + + +@dataclass +class BlogPostContent: + """생성된 블로그 포스트 콘텐츠""" + + title: str + content: str + tags: List[str] + + +@dataclass +class BlogContentRequest: + """블로그 콘텐츠 생성 요청""" + + content_style: str = "informative" # "informative", "promotional", "review" + target_keywords: List[str] = None + include_pricing: bool = True + include_specifications: bool = True + content_length: str = "medium" # "short", "medium", "long" + + +class ProductContentGenerator: + """GPT를 활용한 상품 블로그 콘텐츠 생성""" + + def __init__(self): + # 환경변수에서 OpenAI API 키 로드 + self.openai_api_key = os.getenv("OPENAI_API_KEY") + if not self.openai_api_key: + raise ValueError("OPENAI_API_KEY가 .env.dev 파일에 설정되지 않았습니다.") + + client.api_key = self.openai_api_key + + def generate_blog_content( + self, product_data: ProductData, request: BlogContentRequest + ) -> BlogPostContent: + """상품 데이터를 기반으로 블로그 콘텐츠 생성""" + + # 1. 상품 정보 정리 + product_info = self._format_product_info(product_data, request) + + # 2. 프롬프트 생성 + prompt = self._create_blog_prompt(product_info, request) + + # 3. GPT를 통한 콘텐츠 생성 + try: + + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "system", + "content": "당신은 전문적인 블로그 콘텐츠 작성자입니다. 상품 리뷰와 정보성 콘텐츠를 매력적이고 SEO 친화적으로 작성합니다.", + }, + {"role": "user", "content": prompt}, + ], + temperature=0.7, + max_tokens=2000, + ) + + generated_content = response.choices[0].message.content + + # 4. 콘텐츠 파싱 및 구조화 + return self._parse_generated_content( + generated_content, product_data, request + ) + + except Exception as e: + logging.error(f"콘텐츠 생성 실패: {e}") + return self._create_fallback_content(product_data, request) + + def _format_product_info( + self, product_data: ProductData, request: BlogContentRequest + ) -> str: + """상품 정보를 텍스트로 포맷팅""" + info_parts = [ + f"상품명: {product_data.title}", + ] + + # 가격 정보 추가 + if request.include_pricing and product_data.price: + info_parts.append(f"가격: {product_data.price:,}원") + + # 평점 정보 추가 + if product_data.rating: + info_parts.append(f"평점: {product_data.rating}/5.0") + + # 사양 정보 추가 + if request.include_specifications and product_data.material_info: + info_parts.append("\n상품 사양:") + for key, value in product_data.material_info.items(): + info_parts.append(f"- {key}: {value}") + + # 옵션 정보 추가 + if product_data.options: + info_parts.append(f"\n구매 옵션 ({len(product_data.options)}개):") + for i, option in enumerate(product_data.options[:5], 1): # 처음 5개만 + info_parts.append(f"{i}. {option.get('name', 'N/A')}") + + # 구매 링크 + if product_data.product_url: + info_parts.append(f"\n구매 링크: {product_data.product_url}") + + return "\n".join(info_parts) + + def _create_blog_prompt( + self, product_info: str, request: BlogContentRequest + ) -> str: + """블로그 작성용 프롬프트 생성""" + + # 스타일별 가이드라인 + style_guidelines = { + "informative": "객관적이고 상세한 정보 제공 중심으로, 독자가 제품을 이해할 수 있도록 전문적으로 작성", + "promotional": "제품의 장점과 매력을 강조하며, 구매 의욕을 자극할 수 있도록 매력적으로 작성", + "review": "실제 사용 경험을 바탕으로 한 솔직한 평가와 추천 중심으로 작성", + } + + # 길이별 가이드라인 + length_guidelines = { + "short": "800자 내외의 간결한 내용", + "medium": "1200자 내외의 적당한 길이", + "long": "1500자 이상의 상세한 내용", + } + + style_guide = style_guidelines.get( + request.content_style, style_guidelines["informative"] + ) + length_guide = length_guidelines.get( + request.content_length, length_guidelines["medium"] + ) + + # 키워드 정보 + keywords_text = "" + if request.target_keywords: + keywords_text = f"\n포함할 키워드: {', '.join(request.target_keywords)}" + + prompt = f""" +다음 상품 정보를 바탕으로 매력적인 블로그 포스트를 작성해주세요. + +상품 정보: +{product_info} + +작성 가이드라인: +- 스타일: {style_guide} +- 길이: {length_guide} +- 톤: 친근하면서도 신뢰할 수 있는, 정보 제공 중심{keywords_text} + +작성 요구사항: +1. SEO 친화적이고 클릭하고 싶은 매력적인 제목 +2. 독자의 관심을 끄는 도입부 +3. 상품의 핵심 특징과 장점을 구체적으로 설명 +4. 실제 사용 시나리오나 활용 팁 +5. 구매 결정에 도움이 되는 정보 + +⚠️ 주의: +- 절대로 마지막에 '이 HTML 구조는…' 같은 자기 평가 문장을 추가하지 마세요. +- 출력 시 ```나 ```html 같은 코드 블록 구문을 포함하지 마세요. +- 오직 HTML 태그만 사용하여 구조화된 콘텐츠를 작성해주세요. +(예:
,
{product_data.title}에 대한 상세한 정보를 소개합니다.
+ +판매가: {product_data.price:,}원
+""" + + if product_data.material_info: + content += "상품 구매는 여기에서 가능합니다.
+""" + + return BlogPostContent( + title=title, + content=content, + tags=[product_data.tag] if product_data.tag else ["상품정보"], + ) + + +class ProductBlogPostingService: + """상품 데이터를 Blogger에 포스팅하는 메인 서비스""" + + def __init__(self): + self.content_generator = ProductContentGenerator() + self.blogger_service = BloggerBlogPostAdapter() + + def post_product_to_blogger( + self, product_data: ProductData, request: BlogContentRequest + ) -> dict: + """상품 데이터를 Blogger에 포스팅""" + try: + # 1. GPT를 통한 콘텐츠 생성 + blog_content = self.content_generator.generate_blog_content( + product_data, request + ) + + # 2. Blogger에 포스팅 + self.blogger_service.post_content( + title=blog_content.title, + content=blog_content.content, + tags=blog_content.tags, + ) + + # 3. 성공 결과 반환 + return { + "status": "success", + "platform": "blogger", + "title": blog_content.title, + "tags": blog_content.tags, + "posted_at": datetime.now().isoformat(), + "product_tag": product_data.tag, + } + + except Exception as e: + logging.error(f"Blogger 포스팅 실패: {e}") + # ProductData 객체 기준으로 처리 + return { + "status": "failed", + "error": str(e), + "platform": "blogger", + "attempted_at": datetime.now().isoformat(), + "product_tag": getattr(product_data, "tag", "unknown"), + } + + # def batch_post_products(self, products_data: List[Dict], request: BlogContentRequest) -> List[Dict[str, Any]]: + # """여러 상품을 일괄 포스팅""" + # results = [] + # + # for product_data in products_data: + # result = self.post_product_to_blogger(product_data, request) + # results.append(result) + # + # # API 호출 제한을 고려한 딜레이 + # import time + # time.sleep(3) # 3초 대기 + # + # return results diff --git a/apps/pre-processing-service/app/test/test_product_blog_posting_service.py b/apps/pre-processing-service/app/test/test_product_blog_posting_service.py new file mode 100644 index 00000000..c5b1efde --- /dev/null +++ b/apps/pre-processing-service/app/test/test_product_blog_posting_service.py @@ -0,0 +1,87 @@ +import pytest +from app.service.product_blog_posting_service import ( + ProductBlogPostingService, + BlogContentRequest, + ProductData, +) + +# 샘플 데이터 +sample_product_data = { + "tag": "test001", + "product_url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=902500949447", + "status": "success", + "product_detail": { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=902500949447", + "title": "코닝 적용 가능한 애플 13 강화 필름 iphone16/15promax 휴대 전화 필름 애플 11 안티-peep 및 먼지없는 빈", + "price": 430, + "rating": 5.0, + "options": [ + {"name": "먼지 없는 창고 2차 필름 [코닝글라스 방폭丨초투명]", "stock": 0}, + { + "name": "먼지 없는 창고 2차 필름 [코닝글라스 방폭丨훔쳐보기 방지]", + "stock": 0, + }, + ], + "material_info": { + "상표": "다른", + "재료": "강화 유리", + "필름 종류": "전막", + "크기": "애플 16프로맥스( 6.9inch )", + "적용 모델": "iPhone13 Pro Max", + }, + "product_images": [], + }, + "crawled_at": "2025-09-16 11:49:24", +} + + +@pytest.fixture +def blog_service(): + return ProductBlogPostingService() + + +def test_generate_blog_content(blog_service): + """GPT를 통한 블로그 콘텐츠 생성 테스트""" + request = BlogContentRequest( + content_style="informative", + target_keywords=["아이폰", "강화필름", "보호필름", "스마트폰액세서리"], + include_pricing=True, + content_length="medium", + ) + + product_obj = ProductData.from_dict(sample_product_data) + + # 순수 콘텐츠 생성만 테스트 + blog_content = blog_service.content_generator.generate_blog_content( + product_obj, request + ) + + assert blog_content.title + assert "