383 lines
14 KiB
Python
383 lines
14 KiB
Python
import os
|
|
import uuid
|
|
import logging
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel
|
|
from typing import List, Optional
|
|
|
|
from .config import CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY
|
|
from .db import (
|
|
init_db,
|
|
get_keyword_analyses, get_keyword_analysis, delete_keyword_analysis,
|
|
add_keyword_analysis,
|
|
get_posts, get_post, add_post, update_post, delete_post,
|
|
get_commissions, add_commission, update_commission, delete_commission,
|
|
get_dashboard_stats,
|
|
get_task, create_task, update_task,
|
|
add_brand_link, get_brand_links, update_brand_link, delete_brand_link,
|
|
link_brand_links_to_post,
|
|
)
|
|
from .naver_search import analyze_keyword_with_crawling
|
|
from .content_generator import generate_trend_brief, generate_blog_post, regenerate_blog_post
|
|
from .quality_reviewer import review_post
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI()
|
|
|
|
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in _cors_origins],
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def on_startup():
|
|
init_db()
|
|
os.makedirs("/app/data", exist_ok=True)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/blog-marketing/status")
|
|
def service_status():
|
|
"""서비스 상태 및 설정 현황."""
|
|
return {
|
|
"ok": True,
|
|
"naver_api": bool(NAVER_CLIENT_ID),
|
|
"claude_api": bool(ANTHROPIC_API_KEY),
|
|
}
|
|
|
|
|
|
# ── 키워드 분석 API ──────────────────────────────────────────────────────────
|
|
|
|
class ResearchRequest(BaseModel):
|
|
keyword: str
|
|
|
|
|
|
def _run_research(task_id: str, keyword: str):
|
|
"""BackgroundTask: 네이버 검색 → 키워드 분석 → DB 저장."""
|
|
try:
|
|
update_task(task_id, "processing", 30, "네이버 검색 중...")
|
|
result = analyze_keyword_with_crawling(keyword)
|
|
|
|
update_task(task_id, "processing", 80, "분석 결과 저장 중...")
|
|
saved = add_keyword_analysis(result)
|
|
|
|
update_task(task_id, "succeeded", 100, "분석 완료", result_id=saved["id"])
|
|
except Exception as e:
|
|
logger.exception("Research failed for keyword=%s", keyword)
|
|
update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
@app.post("/api/blog-marketing/research")
|
|
def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
|
|
"""키워드 분석 시작 (BackgroundTask). task_id 즉시 반환."""
|
|
if not NAVER_CLIENT_ID:
|
|
raise HTTPException(status_code=400, detail="Naver API 키가 설정되지 않았습니다")
|
|
if not req.keyword.strip():
|
|
raise HTTPException(status_code=400, detail="키워드를 입력하세요")
|
|
|
|
task_id = str(uuid.uuid4())
|
|
create_task(task_id, "research", {"keyword": req.keyword.strip()})
|
|
background_tasks.add_task(_run_research, task_id, req.keyword.strip())
|
|
return {"task_id": task_id}
|
|
|
|
|
|
@app.get("/api/blog-marketing/research/history")
|
|
def list_research(limit: int = 30):
|
|
return {"analyses": get_keyword_analyses(limit)}
|
|
|
|
|
|
@app.get("/api/blog-marketing/research/{analysis_id}")
|
|
def get_research(analysis_id: int):
|
|
result = get_keyword_analysis(analysis_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/blog-marketing/research/{analysis_id}")
|
|
def remove_research(analysis_id: int):
|
|
if not delete_keyword_analysis(analysis_id):
|
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 작업 상태 폴링 API ──────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/blog-marketing/task/{task_id}")
|
|
def get_task_status(task_id: str):
|
|
task = get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
return task
|
|
|
|
|
|
# ── AI 글 생성 API ──────────────────────────────────────────────────────────
|
|
|
|
class GenerateRequest(BaseModel):
|
|
keyword_id: int # keyword_analyses.id
|
|
|
|
|
|
class LinkRequest(BaseModel):
|
|
url: str
|
|
product_name: str
|
|
keyword_id: Optional[int] = None
|
|
post_id: Optional[int] = None
|
|
description: str = ""
|
|
placement_hint: str = ""
|
|
|
|
|
|
def _run_generate(task_id: str, keyword_id: int):
|
|
"""BackgroundTask: 트렌드 브리프 → 블로그 글 생성 → DB 저장."""
|
|
try:
|
|
analysis = get_keyword_analysis(keyword_id)
|
|
if not analysis:
|
|
update_task(task_id, "failed", 0, "", error="키워드 분석 결과를 찾을 수 없습니다")
|
|
return
|
|
|
|
# 연결된 브랜드커넥트 링크 조회
|
|
brand_links = get_brand_links(keyword_id=keyword_id)
|
|
|
|
update_task(task_id, "processing", 20, "트렌드 브리프 생성 중...")
|
|
trend_brief = generate_trend_brief(analysis)
|
|
|
|
update_task(task_id, "processing", 60, "블로그 글 작성 중...")
|
|
post_data = generate_blog_post(analysis, trend_brief, brand_links=brand_links)
|
|
|
|
update_task(task_id, "processing", 90, "저장 중...")
|
|
saved = add_post({
|
|
"keyword_id": keyword_id,
|
|
"title": post_data["title"],
|
|
"body": post_data["body"],
|
|
"excerpt": post_data["excerpt"],
|
|
"tags": post_data["tags"],
|
|
"status": "draft",
|
|
"trend_brief": trend_brief,
|
|
})
|
|
|
|
# keyword_id에 연결된 링크를 post_id에도 연결
|
|
link_brand_links_to_post(keyword_id=keyword_id, post_id=saved["id"])
|
|
|
|
update_task(task_id, "succeeded", 100, "글 생성 완료", result_id=saved["id"])
|
|
except Exception as e:
|
|
logger.exception("Generate failed for keyword_id=%s", keyword_id)
|
|
update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
@app.post("/api/blog-marketing/generate")
|
|
def start_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
|
|
"""AI 블로그 글 생성 시작. task_id 즉시 반환."""
|
|
if not ANTHROPIC_API_KEY:
|
|
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
|
analysis = get_keyword_analysis(req.keyword_id)
|
|
if not analysis:
|
|
raise HTTPException(status_code=404, detail="키워드 분석 결과를 찾을 수 없습니다")
|
|
|
|
task_id = str(uuid.uuid4())
|
|
create_task(task_id, "generate", {"keyword_id": req.keyword_id})
|
|
background_tasks.add_task(_run_generate, task_id, req.keyword_id)
|
|
return {"task_id": task_id}
|
|
|
|
|
|
# ── 품질 리뷰 API ───────────────────────────────────────────────────────────
|
|
|
|
def _run_review(task_id: str, post_id: int):
|
|
"""BackgroundTask: 블로그 글 품질 리뷰."""
|
|
try:
|
|
post = get_post(post_id)
|
|
if not post:
|
|
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
|
return
|
|
|
|
update_task(task_id, "processing", 50, "품질 리뷰 중...")
|
|
result = review_post(post["title"], post["body"])
|
|
|
|
update_post(post_id, {
|
|
"review_score": result["total"],
|
|
"review_detail": result,
|
|
"status": "reviewed" if result["pass"] else "draft",
|
|
})
|
|
|
|
update_task(task_id, "succeeded", 100, "리뷰 완료", result_id=post_id)
|
|
except Exception as e:
|
|
logger.exception("Review failed for post_id=%s", post_id)
|
|
update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
@app.post("/api/blog-marketing/review/{post_id}")
|
|
def start_review(post_id: int, background_tasks: BackgroundTasks):
|
|
"""블로그 글 품질 리뷰 시작. task_id 즉시 반환."""
|
|
if not ANTHROPIC_API_KEY:
|
|
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
|
post = get_post(post_id)
|
|
if not post:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
|
|
task_id = str(uuid.uuid4())
|
|
create_task(task_id, "review", {"post_id": post_id})
|
|
background_tasks.add_task(_run_review, task_id, post_id)
|
|
return {"task_id": task_id}
|
|
|
|
|
|
# ── 재생성 API ───────────────────────────────────────────────────────────────
|
|
|
|
def _run_regenerate(task_id: str, post_id: int):
|
|
"""BackgroundTask: 피드백 기반 블로그 글 재생성."""
|
|
try:
|
|
post = get_post(post_id)
|
|
if not post:
|
|
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
|
return
|
|
|
|
analysis = get_keyword_analysis(post["keyword_id"]) if post["keyword_id"] else {}
|
|
feedback = post.get("review_detail", {}).get("feedback", "개선이 필요합니다")
|
|
|
|
update_task(task_id, "processing", 50, "글 재생성 중...")
|
|
result = regenerate_blog_post(
|
|
analysis or {"keyword": ""},
|
|
post.get("trend_brief", ""),
|
|
post["body"],
|
|
feedback,
|
|
)
|
|
|
|
update_post(post_id, {
|
|
"title": result["title"],
|
|
"body": result["body"],
|
|
"excerpt": result["excerpt"],
|
|
"tags": result["tags"],
|
|
"status": "draft",
|
|
"review_score": None,
|
|
"review_detail": {},
|
|
})
|
|
|
|
update_task(task_id, "succeeded", 100, "재생성 완료", result_id=post_id)
|
|
except Exception as e:
|
|
logger.exception("Regenerate failed for post_id=%s", post_id)
|
|
update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
@app.post("/api/blog-marketing/regenerate/{post_id}")
|
|
def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
|
|
"""피드백 기반 블로그 글 재생성. task_id 즉시 반환."""
|
|
if not ANTHROPIC_API_KEY:
|
|
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
|
post = get_post(post_id)
|
|
if not post:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
|
|
task_id = str(uuid.uuid4())
|
|
create_task(task_id, "regenerate", {"post_id": post_id})
|
|
background_tasks.add_task(_run_regenerate, task_id, post_id)
|
|
return {"task_id": task_id}
|
|
|
|
|
|
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/blog-marketing/posts")
|
|
def list_posts(status: str = None, limit: int = 50):
|
|
return {"posts": get_posts(status=status, limit=limit)}
|
|
|
|
|
|
@app.get("/api/blog-marketing/posts/{post_id}")
|
|
def get_post_detail(post_id: int):
|
|
post = get_post(post_id)
|
|
if not post:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
return post
|
|
|
|
|
|
@app.put("/api/blog-marketing/posts/{post_id}")
|
|
def edit_post(post_id: int, data: dict):
|
|
result = update_post(post_id, data)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/blog-marketing/posts/{post_id}")
|
|
def remove_post(post_id: int):
|
|
if not delete_post(post_id):
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/blog-marketing/posts/{post_id}/publish")
|
|
def publish_post(post_id: int, data: dict = None):
|
|
"""네이버 URL 등록 + 상태를 published로 변경."""
|
|
naver_url = (data or {}).get("naver_url", "")
|
|
result = update_post(post_id, {"status": "published", "naver_url": naver_url})
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Post not found")
|
|
return result
|
|
|
|
|
|
# ── 브랜드커넥트 링크 API ──────────────────────────────────────────────────
|
|
|
|
@app.post("/api/blog-marketing/links", status_code=201)
|
|
def create_link(req: LinkRequest):
|
|
return add_brand_link(req.model_dump())
|
|
|
|
|
|
@app.get("/api/blog-marketing/links")
|
|
def list_links(post_id: int = None, keyword_id: int = None):
|
|
return {"links": get_brand_links(post_id=post_id, keyword_id=keyword_id)}
|
|
|
|
|
|
@app.put("/api/blog-marketing/links/{link_id}")
|
|
def edit_link(link_id: int, data: dict):
|
|
result = update_brand_link(link_id, data)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Link not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/blog-marketing/links/{link_id}")
|
|
def remove_link(link_id: int):
|
|
if not delete_brand_link(link_id):
|
|
raise HTTPException(status_code=404, detail="Link not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 수익 추적 API ────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/blog-marketing/commissions")
|
|
def list_commissions(post_id: int = None, limit: int = 100):
|
|
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
|
|
|
|
|
|
@app.post("/api/blog-marketing/commissions", status_code=201)
|
|
def create_commission(data: dict):
|
|
return add_commission(data)
|
|
|
|
|
|
@app.put("/api/blog-marketing/commissions/{comm_id}")
|
|
def edit_commission(comm_id: int, data: dict):
|
|
result = update_commission(comm_id, data)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Commission not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/blog-marketing/commissions/{comm_id}")
|
|
def remove_commission(comm_id: int):
|
|
if not delete_commission(comm_id):
|
|
raise HTTPException(status_code=404, detail="Commission not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 대시보드 API ─────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/blog-marketing/dashboard")
|
|
def dashboard():
|
|
return get_dashboard_stats()
|