diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index 41d772a..d3c91da 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -74,8 +74,11 @@ def create_app(): platform=data.get('platform'), images=data.get('images', []), requirement=data.get('requirement'), - toneAndManner=data.get('toneAndManner'), - emotionIntensity=data.get('emotionIntensity'), + storeName=data.get('storeName'), + storeType=data.get('storeType'), + target=data.get('target'), + #toneAndManner=data.get('toneAndManner'), + #emotionIntensity=data.get('emotionIntensity'), menuName=data.get('menuName'), eventName=data.get('eventName'), startDate=data.get('startDate'), diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile new file mode 100644 index 0000000..a478c49 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile @@ -0,0 +1,157 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true), + containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true), + containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h') + ], + volumes: [ + emptyDirVolume(mountPath: '/run/podman', memory: false), + emptyDirVolume(mountPath: '/root/.azure', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + def manifest = "deploy.yaml" + def namespace + + stage("Get Source") { + checkout scm + props = readProperties file: "deployment/deploy_env_vars" + namespace = "${props.namespace}" + + echo "Registry: ${props.registry}" + echo "Image Org: ${props.image_org}" + echo "Team ID: ${props.teamid}" + } + + stage("Setup AKS") { + container('azure-cli') { + withCredentials([azureServicePrincipal('azure-credentials')]) { + sh """ + az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID + az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing + kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - + """ + } + } + } + + stage('Build & Push Docker Image') { + container('podman') { + sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2' + + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "==========================================" + echo "Building smarketing-ai Python Flask application" + echo "Image Tag: ${imageTag}" + echo "==========================================" + + # ACR 로그인 + echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin + + # Docker 이미지 빌드 + podman build \ + -f deployment/container/Dockerfile \ + -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . + + # 이미지 푸시 + podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + """ + } + } + } + + stage('Generate & Apply Manifest') { + container('envsubst') { + withCredentials([ + string(credentialsId: 'secret-key', variable: 'SECRET_KEY'), + string(credentialsId: 'claude-api-key', variable: 'CLAUDE_API_KEY'), + string(credentialsId: 'openai-api-key', variable: 'OPENAI_API_KEY'), + string(credentialsId: 'azure-storage-account-name', variable: 'AZURE_STORAGE_ACCOUNT_NAME'), + string(credentialsId: 'azure-storage-account-key', variable: 'AZURE_STORAGE_ACCOUNT_KEY') + ]) { + sh """ + export namespace=${namespace} + export replicas=${props.replicas} + export resources_requests_cpu=${props.resources_requests_cpu} + export resources_requests_memory=${props.resources_requests_memory} + export resources_limits_cpu=${props.resources_limits_cpu} + export resources_limits_memory=${props.resources_limits_memory} + export upload_folder=${props.upload_folder} + export max_content_length=${props.max_content_length} + export allowed_extensions=${props.allowed_extensions} + export server_host=${props.server_host} + export server_port=${props.server_port} + export azure_storage_container_name=${props.azure_storage_container_name} + + # 이미지 경로 환경변수 설정 + export smarketing_image_path=${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + # Sensitive 환경변수 설정 (Jenkins Credentials에서) + export secret_key=\$SECRET_KEY + export claude_api_key=\$CLAUDE_API_KEY + export openai_api_key=\$OPENAI_API_KEY + export azure_storage_account_name=\$AZURE_STORAGE_ACCOUNT_NAME + export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY + + # manifest 생성 + envsubst < deployment/${manifest}.template > deployment/${manifest} + echo "Generated manifest file:" + cat deployment/${manifest} + """ + } + } + + container('azure-cli') { + sh """ + kubectl apply -f deployment/${manifest} + + echo "Waiting for smarketing deployment to be ready..." + kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s + + echo "==========================================" + echo "Getting LoadBalancer External IP..." + + # External IP 확인 (최대 5분 대기) + for i in {1..30}; do + EXTERNAL_IP=\$(kubectl -n ${namespace} get service smarketing-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + if [ "\$EXTERNAL_IP" != "" ] && [ "\$EXTERNAL_IP" != "null" ]; then + echo "External IP assigned: \$EXTERNAL_IP" + break + fi + echo "Waiting for External IP... (attempt \$i/30)" + sleep 10 + done + + # 서비스 상태 확인 + kubectl -n ${namespace} get pods -l app=smarketing + kubectl -n ${namespace} get service smarketing-service + + echo "==========================================" + echo "Deployment Complete!" + echo "Service URL: http://\$EXTERNAL_IP:${props.server_port}" + echo "Health Check: http://\$EXTERNAL_IP:${props.server_port}/health" + echo "==========================================" + """ + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/Jenkinsfile_ArgoCD b/smarketing-ai/deployment/Jenkinsfile_ArgoCD new file mode 100644 index 0000000..1f86a02 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile_ArgoCD @@ -0,0 +1,170 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true), + containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true) + ], + volumes: [ + emptyDirVolume(mountPath: '/run/podman', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + + stage("Get Source") { + checkout scm + props = readProperties file: "deployment/deploy_env_vars" + } + + stage('Build & Push Docker Image') { + container('podman') { + sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2' + + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "==========================================" + echo "Building smarketing-ai for ArgoCD GitOps" + echo "Image Tag: ${imageTag}" + echo "==========================================" + + # ACR 로그인 + echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin + + # Docker 이미지 빌드 + podman build \ + -f deployment/container/Dockerfile \ + -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . + + # 이미지 푸시 + podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + """ + } + } + } + + stage('Update Manifest Repository') { + container('git') { + withCredentials([usernamePassword( + credentialsId: 'github-credentials-${props.teamid}', + usernameVariable: 'GIT_USERNAME', + passwordVariable: 'GIT_PASSWORD' + )]) { + sh """ + # Git 설정 + git config --global user.email "jenkins@company.com" + git config --global user.name "Jenkins CI" + + # Manifest 저장소 클론 (팀별 저장소로 수정 필요) + git clone https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/your-team/smarketing-ai-manifest.git + cd smarketing-ai-manifest + + echo "==========================================" + echo "Updating smarketing-ai manifest repository:" + echo "New Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + + # smarketing deployment 파일 업데이트 + if [ -f "smarketing/smarketing-deployment.yaml" ]; then + # 이미지 태그 업데이트 + sed -i "s|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}|g" \ + smarketing/smarketing-deployment.yaml + + echo "Updated smarketing deployment to image tag: ${imageTag}" + cat smarketing/smarketing-deployment.yaml | grep "image:" + else + echo "Warning: smarketing-deployment.yaml not found" + echo "Creating manifest directory structure..." + + # 기본 구조 생성 + mkdir -p smarketing + + # 기본 deployment 파일 생성 + cat > smarketing/smarketing-deployment.yaml << EOF +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: smarketing + labels: + app: smarketing +spec: + replicas: 1 + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: 256m + memory: 512Mi + limits: + cpu: 1024m + memory: 2048Mi + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} +EOF + echo "Created basic smarketing-deployment.yaml" + fi + + # 변경사항 커밋 및 푸시 + git add . + git commit -m "Update smarketing-ai image tag to ${imageTag} + + Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + Build: ${env.BUILD_NUMBER} + Branch: ${env.BRANCH_NAME} + Commit: ${env.GIT_COMMIT}" + + git push origin main + + echo "==========================================" + echo "ArgoCD GitOps Update Completed!" + echo "Updated Service: smarketing-ai:${imageTag}" + echo "ArgoCD will automatically detect and deploy these changes." + echo "==========================================" + """ + } + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy.yaml.template b/smarketing-ai/deployment/deploy.yaml.template new file mode 100644 index 0000000..2f35b44 --- /dev/null +++ b/smarketing-ai/deployment/deploy.yaml.template @@ -0,0 +1,113 @@ +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: smarketing-config + namespace: ${namespace} +data: + SERVER_HOST: "${server_host}" + SERVER_PORT: "${server_port}" + UPLOAD_FOLDER: "${upload_folder}" + MAX_CONTENT_LENGTH: "${max_content_length}" + ALLOWED_EXTENSIONS: "${allowed_extensions}" + AZURE_STORAGE_CONTAINER_NAME: "${azure_storage_container_name}" + +--- +# Secret +apiVersion: v1 +kind: Secret +metadata: + name: smarketing-secret + namespace: ${namespace} +type: Opaque +stringData: + SECRET_KEY: "${secret_key}" + CLAUDE_API_KEY: "${claude_api_key}" + OPENAI_API_KEY: "${openai_api_key}" + AZURE_STORAGE_ACCOUNT_NAME: "${azure_storage_account_name}" + AZURE_STORAGE_ACCOUNT_KEY: "${azure_storage_account_key}" + +--- +# Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: ${namespace} + labels: + app: smarketing +spec: + replicas: ${replicas} + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: ${smarketing_image_path} + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + livenessProbe: + httpGet: + path: /health + port: 5001 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 5001 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} + +--- +# Service (LoadBalancer type for External IP) +apiVersion: v1 +kind: Service +metadata: + name: smarketing-service + namespace: ${namespace} + labels: + app: smarketing +spec: + type: LoadBalancer + ports: + - port: 5001 + targetPort: 5001 + protocol: TCP + name: http + selector: + app: smarketing \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy_env_vars b/smarketing-ai/deployment/deploy_env_vars new file mode 100644 index 0000000..6f33b33 --- /dev/null +++ b/smarketing-ai/deployment/deploy_env_vars @@ -0,0 +1,27 @@ +# Team Settings +teamid=won +root_project=smarketing-ai +namespace=smarketing + +# Container Registry Settings +registry=acrdigitalgarage02.azurecr.io +image_org=won + +# Application Settings +replicas=1 + +# Resource Settings +resources_requests_cpu=256m +resources_requests_memory=512Mi +resources_limits_cpu=1024m +resources_limits_memory=2048Mi + +# Flask App Settings (non-sensitive) +upload_folder=/app/uploads +max_content_length=16777216 +allowed_extensions=png,jpg,jpeg,gif,webp +server_host=0.0.0.0 +server_port=5001 + +# Azure Storage Settings (non-sensitive) +azure_storage_container_name=ai-content \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/deployment.yaml b/smarketing-ai/deployment/manifest/deployment.yaml index 1a5df1d..cc53cb5 100644 --- a/smarketing-ai/deployment/manifest/deployment.yaml +++ b/smarketing-ai/deployment/manifest/deployment.yaml @@ -19,7 +19,7 @@ spec: - name: acr-secret containers: - name: smarketing - image: dg0408cr.azurecr.io/smarketing-ai:latest + image: acrdigitalgarage02.azurecr.io/smarketing-ai:latest imagePullPolicy: Always ports: - containerPort: 5001 diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml index d013ead..e489d01 100644 --- a/smarketing-ai/deployment/manifest/secret.yaml +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -5,6 +5,10 @@ metadata: namespace: smarketing type: Opaque stringData: - SECRET_KEY: "your-secret-key-change-in-production" - CLAUDE_API_KEY: "your-claude-api-key" - OPENAI_API_KEY: "your-openai-api-key" \ No newline at end of file + SECRET_KEY: + CLAUDE_API_KEY: + OPENAI_API_KEY: + AZURE_STORAGE_ACCOUNT_NAME: "stdigitalgarage02" + AZURE_STORAGE_ACCOUNT_KEY: + AZURE_STORAGE_CONTAINER_NAME: "ai-content" + diff --git a/smarketing-ai/deployment/manifest/service.yaml b/smarketing-ai/deployment/manifest/service.yaml index 87ba6f0..08dc1e8 100644 --- a/smarketing-ai/deployment/manifest/service.yaml +++ b/smarketing-ai/deployment/manifest/service.yaml @@ -6,7 +6,7 @@ metadata: labels: app: smarketing spec: - type: ClusterIP + type: LoadBalancer ports: - port: 5001 targetPort: 5001 diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index 41b36d2..3f6952d 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -16,9 +16,12 @@ class SnsContentGetRequest: contentType: str platform: str images: List[str] # 이미지 URL 리스트 + target : Optional[str] = None # 타켓 requirement: Optional[str] = None - toneAndManner: Optional[str] = None - emotionIntensity: Optional[str] = None + storeName: Optional[str] = None + storeType: Optional[str] = None + #toneAndManner: Optional[str] = None + #emotionIntensity: Optional[str] = None menuName: Optional[str] = None eventName: Optional[str] = None startDate: Optional[date] = None # LocalDate -> date diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index 24fa7a5..680a1e7 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -16,6 +16,1325 @@ class SnsContentService: self.ai_client = AIClient() self.image_processor = ImageProcessor() + # 블로그 글 예시 + self.blog_example = [ + { + "raw_html": """
+
+
+
+
+

팔공

중국음식하면 짬뽕이 제일 먼저 저는 떠오릅니다. 어릴 적 부터 짜장은 그닥 좋아하지 않았기에 지금도 짜장 보다는 짬뽕 그리고 볶음밥을 더 사랑합니다.(탕수육도 그닥 좋아하지는 않습니다) 지난 주말 11시30분쯤 갔다가 기겁(?)을 하고 일산으로 갔었던 기억이 납니다. 이날은 평일 조금 늦은 시간이기에 웨이팅이 없겠지 하고 갔습니다. 다행히 웨이팅은 없는데 홀에 딱 한자리가 있어서 다행히 착석을 하고 주문을 합니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

중화요리 팔공

위치안내: 서울 관악구 남부순환로 1680

영업시간: 11시 20분 ~ 21시 30분( 15시 ~ 17시 브레이크타임, 일요일 휴무)

메뉴: 짜장면, 해물짬뽕, 고기짬뽕, 볶음밥, 탕수육등

+
+
+
+
+
+
+
+
+

3명이 주문한 메뉴는 짜장면, 옛날볶음밥, 팔공해물짬뽕 2개 총 4가지 주문을 합니다.

+
+
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ + +
+
+ +
+
+
+
+

오랜만에 오셨네요 하셔서 " 이젠 와인 못 마시겠네요 "했더니 웃으시더군요 ㅎ

https://blog.naver.com/melburne/222278591313

+
+
+
+ +
+
+ + +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

차림료

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

밑반찬들 ㅎ

요즘 짜사이 주는 곳 참 좋아합니다. 어디였더라? 짜사이가 엄청 맛있었던 곳이 얼마 전 있었는데 음.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

옛날볶음밥(12,000원)

불맛나고 고슬고슬 잘 볶아낸 볶음밥에 바로 볶아서 내어주는 짜장까지 정말이지 훌륭한 볶음밥입니다. 오랜만에 만나다보니 흥문을 ㅎ

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

고슬고슬 기름기 없이 볶아내서 내어주십니다. 3명이서 총 4개의 메뉴를 주문했습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

후라이가 아쉽네요. 튀긴 옛날 후라이가 좋은데 아습입니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이집 계란국도 헛투루 내어주지 않으십니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

짜장과 함께 먹는 볶음밥은 역시 굿입니다. 맛나네요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

짜장면(10.000원)

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

일반짜장면이라고 하기보다는 채소도 큼직한 간짜장이라고 보시는 게 맞을 거 같습니다,.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

면에 짜장이 잘 베이면서 진득한게 끝내주죠. 저는 한 젓가락 조금 얻어서 맛을 봤는데 역시나 좋네요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

팔공해물짬뽕(13,000원)

최근래 먹은 해물짬뽕 중에서 해산물이 제일 많이 들어 있다고 해야할까요? 큼직큼직하게 들어 있으면서 묵직한 듯 한게 눈으로만 봐도 '맛있겠구나' 라는 생각이 팍팍 들었습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

처음 나온 볶음밥은 셋이서 맛나게 먹고 각자의 음식을 탐닉하기 시작합니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

탱글탱글한 해물들이 어짜피 냉동이겠지만 그래도 싱싱(?)한 듯 맛있습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

면발도 좋고 캬~...

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비싼(?)선동오징어도 푸짐하게 들어있네요. 대왕이, 솔방울 이런 거 없습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

맛있는 짬뽕은 해산물부터 국물까지 다 맛있습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

줄을 서는 게 무서워서 국물 한방울 안남기고 클리어 했습니다. (국물이 구수하면서 적당히 묵직하고 정말 맛있습니다.)

+
+
+
+ +
+
+
+
+

최종평가: 올해 먹은 짬뽕 중 최고라고 감히 말을 할 수 있을 거 같습니다. 예전보다 더 맛있어 졌으니 사람이 더 많아졌겠죠. 참고로 옛날고기짬뽕은 1시30분전에 솔드아웃된다고 합니다.

+
+
+
+
+
+
+
+
+

+
+
+
+ +
""", + "title": "팔공", + "summary": "중화요리 맛집 홍보" + }, + { + "raw_html": + """
+
+
+
+
+

[남천동 맛집] 안목 - 훌륭한 돼지국밥 한 그릇

미쉐린에 선택한 식당에 특별히 호감이 가는 것은 아니다.

하지만 궁금하기는 하다.

어떤 점에서 좋게 보고 선정을 한 것인지 궁금했다.

내가 가본 식당이라면 판단하면 되겠지만 가보지 않은 식당이라면 그 궁금증은 더 크다.

특히 가장 대중적인 음식이라면 더 클 것이다.

부산의 미쉐린 빕구르망에 2년 연속 선정한 돼지국밥집이 있다.

오가며 보기는 했지만 아직 가보진 못했다.

일부러 찾아가 보았다.

남천동의 "안목"이다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

정문 사진을 찍지 못해서 구글에서 하나 가져왔다. 밖에서 봐도 돼지국밥집 같아 보이지 않는다. 깔끔하고 모던하다.

남천동 등기소 바로 옆 건물이다. 주차장은 별도로 없으니 뒷골목의 주차장을 이용하여야 한다.

그런데 상호의 느낌은 일본풍같이 느껴진다. 혹시 그 뜻을 아시는 분들은 좀 알려주시면 고맙겠다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

좌석은 테이블은 없고 카운터석으로만 되어 있다. 최근 이름난 돼지국밥집들은 다 이런 식으로 만드는 것 같다.

전에 지나다 줄을 서는 것을 보았는데 이날 비가 와서 그랬는지 한가하다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

메뉴가 심플하다. 그냥 돼지국밥에 머릿고기 국밥 정도이다. 수육과 냉제육이 있는데 다음에 가게 되면 먹어보고 싶다.

가격은 비싸지 않은 것은 아닌데 더 비싸지 않아서 다행스럽다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

첨가할 수 있는 여러 가지

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

이런 것들이 있는데 마늘만 넣어 먹었다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

내가 주문한 머릿고기 국밥이다. 1인분씩 담겨 나온다.

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

머리 위의 선반에 쟁반이 올려져 있으면 그것을 내가 받아서 먹어야 한다. 반찬은 특별한 것은 없는데 이날 풋고추가 맛있었다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

굉장히 뽀얀 국물의 국밥이다. 머릿고기가 올려져 있다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이것은 아내가 먹은 그냥 돼지국밥이다. 고기만 다른 국밥이다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

국밥에는 간이 되어 있어서 더 넣지 않아도 충분히 먹을 수 있었다. 그러니 다진 양념이나 새우젓은 맛을 보고 첨가하시길....

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

일본 라멘에 넣는 마늘을 짜서 넣는다. 하나 정도면 충분하겠다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

맛있게 잘 먹었다.

맛있다. 쵸 근래 너무 저가의 돼지국밥만 먹고 다녀서인지 안목의 국밥은 맛있었다.

국물이 너무 무겁지도 않으면서도 진득했다.

완성도가 높다. 국물은 손가락에 꼽을 정도로 괜찮았다.

고기의 품질도 좋았고 손질도 잘했다. 부드럽고 또 비계 부분은 쫄깃했다.

다만 고기가 많아 보이지만 한 점 한 점이 굉장히 얇아서 무게로 치면 그렇게 많은 양은 아닐 것이다.

그리고 국밥 전체적으로 양은 그다지 많은 편은 아니다.

이 정도의 맛이면 미쉐린 빕구르망에 선정되는 것인지는 모르겠지만 나로서는 충분하다고 느껴진다.

내가 추구하는 수더분하고 푸짐한 국밥하고는 반대편에 있는 국밥이지만 완성도가 높으니 다 괜찮아 보인다.

좀 편하게 갈 수 있다면 가끔 가고 싶다.

서면과 부산역에 분점이 있다고 하니 그곳이 좀 편하겠다.

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ + +
+
+ +
+
+
+
+

+
+
+
+ +
""", + "title": "안목", + "summary": "국밥 맛집 홍보" + }, + { + "raw_html": """
+
+
+
+
+ + + +
+
+
+
+
+
+
+

서울 미쉐린맛집 한식전문 목멱산방

-투쁠한우 육회비빔밥

-검은깨두부 보쌈

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

서울 중구 퇴계로20길 71

영업시간

매일

11:00 - 20:00

라스트오더

19:20

전화

02-318-4790

+
+
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

서울 남산은 참 묘한 매력이 있는 곳 같아요!

도시 속인데도 한 발짝만 올라오면

바람도 다르고, 공기도 다르고,

마음까지 탁 트이는 그런 느낌!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

그런 남산 한켠에 있는

서울 미쉐린 맛집

목멱산방 본점에서

특별한 한 끼를 즐기고 왔어요!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

식사 중간중간 보니 외국인 관광객도 많았고

데이트나 가족 외식으로 많이들 오더라고요~

실내는 군더더기 없이 깔끔하고

모던한 느낌이라

전통 한식을

더 세련되게 느낄 수 있어요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

주문은 셀프 방식으로

키오스크로 하면돼요~

방송에도 여러번 나오고

미쉐린 맛집답게

주말에는 사람이 많아요!

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이날 저희가 선택한 메뉴는

검은깨두부와 보쌈,

그리고

시그니처 메뉴인

투뿔한우 육회비빔밥을

주문했는데

기대 이상이었어요!

+
+
+
+ +
+
+
+
+

검은깨두부&보쌈

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

먼저 검은깨두부와 보쌈!!

검은깨 두부는

보기만 해도

고소한 향이 물씬 풍기는것같고

입에 넣자마자 사르르 녹아요!!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

정말 진한 고소함이 입안에 퍼지는데,

이게 그냥 두부가 아니라는 걸

한입만 먹어도 느낄 수 있어요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

그 두부와 함께 나오는 보쌈은

지방과 살코기 비율이 완벽해서

쫀득하면서도 부드러워요.

거기에 곁들여지는

볶음김치와 특제 야채무침이

보쌈 맛을 확 살려줘서,

딱 한식의 진수라는 말이

떠오르더라고요!

+
+
+
+ +
+
+
+
+

투쁠한우 육회비빔밥

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

대망의 투쁠한우 육회비빔밥!

비주얼도 예쁘고

정말 먹음직 스러웠어요!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이건 먼저 육회만 따로 맛봤는데,

신선한 투뿔 채끝살에

유자청과 꿀로 살짝 단맛을 더한

양념이 어우러져,

하나도 느끼하지 않고 깔끔했어요.!!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비빔밥은 나물과 함께

조심스럽게 비벼 한입 먹었을 때,

고추장을 넣지 않고도

양념된 육회와 참기름만으로

깊은 맛이 나는 게,

정말 재료 하나하나에

얼마나 정성을 들였는지

알겠더라고요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비빔밥 안에 들어가는 나물도

건나물, 생야채, 표고버섯,

도라지, 고사리 등

제철에 맞춰 엄선된

나물들이 들어가는데,

하나하나 다 본연의 맛이 좋았어요~

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

삼광쌀로 지은 밥도 맛있더라구요~

밥 한 숟가락에

입안이 꽉 차는 느낌이 넘 좋았어요!

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

함께 주문하고 싶은 사이드 메뉴는

바로 치즈김치전!

피자치즈와 모짜렐라가

가득 들어간 김치전인데,

겉은 바삭하고 속은 촉촉한 게

비빔밥이랑 궁합 최고예요.

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

술 한잔 곁들이고 싶다면,

비빔밥 전용 막걸리도 있어요.

‘한 잔 막걸리’라는 이름답게

식전–식중–식후로 나눠 마시는 재미가 있어요.

과일향도 은은하고,

단맛과 신맛이 균형 잡혀 있어서

비빔밥과 찰떡이에요.

남산 산책하다가,

혹은 명동역 근처로

들리기 좋은 곳이랍니다^^

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ +
+
+ +
+
+
+
+

+
+
+
+ +
""", + "title": "목멱산방", + "summary": "한식 맛집 홍보" + } + ] + # 인스타 글 예시 + self.insta_example = [ + { + "caption": """힘든 월요일 잘 이겨내신 여러분~~~ + 소나기도 내리고 힘드셨을텐데 + 오늘 하루 고생 많으셨어요~~^^ + 고생한 나를 위해 시원한 맥주에 + 낙곱새~~기가 막히죠??낙지에 대창올리고 + 그 위에 새우~화룡점정으로 생와사비~ + 그 맛은 뭐 말씀 안드려도 여러분들이 + 더 잘 아실거예요~~그럼 다들 낙곱새 고고~~""", + "title": "국민 낙곱새", + "summary": "낙곱새 맛집 홍보" + }, + { + "caption": """안녕하세요! 타코몰리김포점입니다! + 타코몰리는 멕시코 문화와 풍부한맛을 경험할 수 있는 특별한 공간입니다.🎉 + + 🌶 대표 메뉴를 맛보세요 + 수제 타코, 바삭한 퀘사디아, 풍성한 부리또로 다양한 맛을 즐길 수 있습니다. + + 📸 특별한 순간을 담아보세요 + #타코몰리김포 해시태그와 함께 여러분의 멋진 사진을 공유해주세요. + 이벤트가 기다리고 있답니다!! + (새우링/치즈스틱/음료 택1) + + 📍 위치 + 김포한강 11로 140번길 15-2 + + 멕시코의 맛과 전통에 푹 빠져보세요! + 언제든지 여러분을 기다리고 있겠습니다🌟""", + "title": "타코몰리", + "summary": "멕시칸 맛집 홍보" + }, + { + "caption":"""📣명륜진사갈비 신메뉴 3종 출시! + + 특제 고추장 양념에 마늘과 청양고추를 더해 + 매콤한 불맛이 일품인 #매콤불고기 🌶️ + + 특제 간장 양념에 마늘과 청양고추를 더해 + 달콤한 감칠맛이 있는 #달콤불고기 🍯 + + 갈비뼈에 붙어있는 부위로 일반 삼겹살보다 + 더욱 깊은 맛과 풍미를 가진 #삼겹갈비 까지🍖 + + 신메뉴로 더욱 풍성해진 명륜진사갈비에서 + 연말 가족/단체모임을 즐겨보세요! + + ※ 신메뉴는 지점에 따라 탄력적으로 운영되고 있으니, + 자세한 문의는 방문하실 매장으로 확인 부탁드립니다.""", + "title": "명륜진사갈비", + "summary": "갈비 맛집 홍보" + } + ] + # 플랫폼별 콘텐츠 특성 정의 (대폭 개선) self.platform_specs = { '인스타그램': { @@ -69,24 +1388,24 @@ class SnsContentService: } # 톤앤매너별 스타일 (플랫폼별 세분화) - self.tone_styles = { - '친근한': { - '인스타그램': '반말, 친구같은 느낌, 이모티콘 많이 사용', - '네이버 블로그': '존댓말이지만 따뜻하고 친근한 어조' - }, - '정중한': { - '인스타그램': '정중하지만 접근하기 쉬운 어조', - '네이버 블로그': '격식 있고 신뢰감 있는 리뷰 스타일' - }, - '재미있는': { - '인스타그램': '유머러스하고 트렌디한 표현', - '네이버 블로그': '재미있는 에피소드가 포함된 후기' - }, - '전문적인': { - '인스타그램': '전문성을 어필하되 딱딱하지 않게', - '네이버 블로그': '전문가 관점의 상세한 분석과 평가' - } - } + # self.tone_styles = { + # '친근한': { + # '인스타그램': '반말, 친구같은 느낌, 이모티콘 많이 사용', + # '네이버 블로그': '존댓말이지만 따뜻하고 친근한 어조' + # }, + # '정중한': { + # '인스타그램': '정중하지만 접근하기 쉬운 어조', + # '네이버 블로그': '격식 있고 신뢰감 있는 리뷰 스타일' + # }, + # '재미있는': { + # '인스타그램': '유머러스하고 트렌디한 표현', + # '네이버 블로그': '재미있는 에피소드가 포함된 후기' + # }, + # '전문적인': { + # '인스타그램': '전문성을 어필하되 딱딱하지 않게', + # '네이버 블로그': '전문가 관점의 상세한 분석과 평가' + # } + # } # 카테고리별 플랫폼 특화 키워드 self.category_keywords = { @@ -105,11 +1424,11 @@ class SnsContentService: } # 감정 강도별 표현 - self.emotion_levels = { - '약함': '은은하고 차분한 표현', - '보통': '적당히 활기찬 표현', - '강함': '매우 열정적이고 강렬한 표현' - } + # self.emotion_levels = { + # '약함': '은은하고 차분한 표현', + # '보통': '적당히 활기찬 표현', + # '강함': '매우 열정적이고 강렬한 표현' + # } # 이미지 타입 분류를 위한 키워드 self.image_type_keywords = { @@ -137,6 +1456,12 @@ class SnsContentService: # 플랫폼별 특화 프롬프트 생성 prompt = self._create_platform_specific_prompt(request, image_analysis, image_placement_plan) + # blog_example을 프롬프트에 추가 + if request.platform == '네이버 블로그' and hasattr(self, 'blog_example') and self.blog_example: + prompt += f"\n\n**참고 예시:**\n{str(self.blog_example)}\n위 예시를 참고하여 점주의 입장에서 가게 홍보 게시물을 작성해주세요." + elif hasattr(self, 'insta_example') and self.insta_example : + prompt += f"\n\n**참고 예시:**\n{str(self.insta_example)}\n위 예시를 참고하여 점주의 입장에서 가게 홍보 게시물을 작성해주세요." + # AI로 콘텐츠 생성 generated_content = self.ai_client.generate_text(prompt, max_tokens=1500) @@ -325,7 +1650,7 @@ class SnsContentService: 플랫폼별 특화 프롬프트 생성 """ platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) - tone_style = self.tone_styles.get(request.toneAndManner, {}).get(request.platform, '친근하고 자연스러운 어조') + #tone_style = self.tone_styles.get(request.toneAndManner, {}).get(request.platform, '친근하고 자연스러운 어조') # 이미지 설명 추출 image_descriptions = [] @@ -335,14 +1660,14 @@ class SnsContentService: # 플랫폼별 특화 프롬프트 생성 if request.platform == '인스타그램': - return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions) + return self._create_instagram_prompt(request, platform_spec, image_descriptions) elif request.platform == '네이버 블로그': - return self._create_naver_blog_prompt(request, platform_spec, tone_style, image_descriptions, + return self._create_naver_blog_prompt(request, platform_spec, image_descriptions, image_placement_plan) else: - return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions) + return self._create_instagram_prompt(request, platform_spec, image_descriptions) - def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str, + def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, image_descriptions: list) -> str: """ 인스타그램 특화 프롬프트 @@ -351,6 +1676,9 @@ class SnsContentService: prompt = f""" 당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요. +**🍸 가게 정보:** +- 가게명: {request.storeName} +- 업종 : {request.storeType} **🎯 콘텐츠 정보:** - 제목: {request.title} @@ -358,12 +1686,12 @@ class SnsContentService: - 콘텐츠 타입: {request.contentType} - 메뉴명: {request.menuName or '특별 메뉴'} - 이벤트: {request.eventName or '특별 이벤트'} +- 독자층: {request.target} **📱 인스타그램 특화 요구사항:** - 글 구조: {platform_spec['content_structure']} - 최대 길이: {platform_spec['max_length']}자 - 해시태그: {platform_spec['hashtag_count']}개 내외 -- 톤앤매너: {tone_style} **✨ 인스타그램 작성 가이드라인:** {chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} @@ -385,14 +1713,16 @@ class SnsContentService: 5. 줄바꿈을 활용하여 가독성 향상 6. 해시태그는 본문과 자연스럽게 연결되도록 배치 -**특별 요구사항:** -{request.requirement or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'} +**필수 요구사항:** +{request.requirement #or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' +} 인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요. +필수 요구사항을 반드시 참고하여 작성해주세요. """ return prompt - def _create_naver_blog_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str, + def _create_naver_blog_prompt(self, request: SnsContentGetRequest, platform_spec: dict, image_descriptions: list, image_placement_plan: Dict[str, Any]) -> str: """ 네이버 블로그 특화 프롬프트 (이미지 배치 계획 포함) @@ -415,17 +1745,21 @@ class SnsContentService: prompt = f""" 당신은 네이버 블로그 맛집 리뷰 전문가입니다. 검색 최적화와 정보 제공을 중시하는 네이버 블로그 특성에 맞는 게시물을 작성해주세요. +**🍸 가게 정보:** +- 가게명: {request.storeName} +- 업종 : {request.storeType} + **📝 콘텐츠 정보:** - 제목: {request.title} - 카테고리: {request.category} - 콘텐츠 타입: {request.contentType} - 메뉴명: {request.menuName or '대표 메뉴'} - 이벤트: {request.eventName or '특별 이벤트'} +- 독자층: {request.target} **🔍 네이버 블로그 특화 요구사항:** - 글 구조: {platform_spec['content_structure']} - 최대 길이: {platform_spec['max_length']}자 -- 톤앤매너: {tone_style} - SEO 최적화 필수 **📚 블로그 작성 가이드라인:** @@ -440,12 +1774,6 @@ class SnsContentService: - 필수 키워드: {', '.join(seo_keywords[:8])} - 카테고리 키워드: {', '.join(category_keywords[:5])} -**📖 블로그 포스트 구조 (이미지 배치 포함):** -1. **인트로**: 방문 동기와 첫인상 + [IMAGE_1] 배치 -2. **매장 정보**: 위치, 운영시간, 분위기 + [IMAGE_2, IMAGE_3] 배치 -3. **메뉴 소개**: 주문한 메뉴와 상세 후기 + [IMAGE_4, IMAGE_5] 배치 -4. **총평**: 재방문 의향과 추천 이유 + [IMAGE_6] 배치 - **💡 콘텐츠 작성 지침:** 1. 검색자의 궁금증을 해결하는 정보 중심 작성 2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함 @@ -459,10 +1787,13 @@ class SnsContentService: - [IMAGE_2]: 두 번째 이미지 배치 위치 - 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 -**특별 요구사항:** -{request.requirement or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'} +**필수 요구사항:** +{request.requirement + # or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' +} 네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. +필수 요구사항을 반드시 참고하여 작성해주세요. 이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요. """ return prompt @@ -527,8 +1858,20 @@ class SnsContentService: # 1. literal \n 문자열을 실제 줄바꿈으로 변환 content = content.replace('\\n', '\n') + # 2. 인스타그램인 경우 첫 번째 이미지를 맨 위에 배치 ⭐ 새로 추가! + images_html_content = "" + if request.platform == '인스타그램' and request.images and len(request.images) > 0: + # 모든 이미지를 통일된 크기로 HTML 변환 (한 줄로 작성!) + for i, image_url in enumerate(request.images): + # ⭐ 핵심: 모든 HTML을 한 줄로 작성해서
변환 문제 방지 + image_html = f'
이미지 {i + 1}
' + images_html_content += image_html + "\n" + + # 이미지를 콘텐츠 맨 앞에 추가 + content = images_html_content + content + # 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환 - if request.platform == '네이버 블로그' and image_placement_plan: + elif request.platform == '네이버 블로그' and image_placement_plan: content = self._replace_image_tags_with_html(content, image_placement_plan, request.images) # 3. 실제 줄바꿈을
태그로 변환 @@ -537,12 +1880,28 @@ class SnsContentService: # 4. 추가 정리: \r, 여러 공백 정리 content = content.replace('\\r', '').replace('\r', '') - # 5. 여러 개의
태그를 하나로 정리 + # 6. 여러 개의
태그를 하나로 정리 import re content = re.sub(r'(
\s*){3,}', '

', content) - # 6. 해시태그를 파란색으로 스타일링 - content = re.sub(r'(#[\w가-힣]+)', r'\1', content) + # 7. ⭐ 간단한 해시태그 스타일링 (CSS 충돌 방지) + import re + # style="..." 패턴을 먼저 찾아서 보호 + style_patterns = re.findall(r'style="[^"]*"', content) + protected_content = content + + for i, pattern in enumerate(style_patterns): + protected_content = protected_content.replace(pattern, f'___STYLE_{i}___') + + # 이제 안전하게 해시태그 스타일링 + protected_content = re.sub(r'(#[\w가-힣]+)', r'\1', + protected_content) + + # 보호된 스타일 복원 + for i, pattern in enumerate(style_patterns): + protected_content = protected_content.replace(f'___STYLE_{i}___', pattern) + + content = protected_content # 플랫폼별 헤더 스타일 platform_style = ""