mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2026-06-13 03:09:11 +00:00
네이버 블로그 ì 배포 개발(ì이미지 없음)
This commit is contained in:
@@ -1,27 +1,28 @@
|
||||
package com.kt.distribution.adapter;
|
||||
|
||||
import com.kt.distribution.client.NaverBlogClient;
|
||||
import com.kt.distribution.dto.ChannelDistributionResult;
|
||||
import com.kt.distribution.dto.ChannelType;
|
||||
import com.kt.distribution.dto.DistributionRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Naver Blog Adapter
|
||||
* Naver Blog 포스팅 API 호출
|
||||
* Naver Blog 포스팅 (Playwright 기반)
|
||||
*
|
||||
* @author System Architect
|
||||
* @since 2025-10-23
|
||||
* @author Backend Developer
|
||||
* @since 2025-10-29
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class NaverAdapter extends AbstractChannelAdapter {
|
||||
|
||||
@Value("${channel.apis.naver.url}")
|
||||
private String apiUrl;
|
||||
private final NaverBlogClient naverBlogClient;
|
||||
|
||||
@Override
|
||||
public ChannelType getChannelType() {
|
||||
@@ -30,16 +31,35 @@ public class NaverAdapter extends AbstractChannelAdapter {
|
||||
|
||||
@Override
|
||||
protected ChannelDistributionResult executeDistribution(DistributionRequest request) {
|
||||
log.debug("Calling Naver API: url={}, eventId={}", apiUrl, request.getEventId());
|
||||
log.debug("Posting to Naver Blog: eventId={}, title={}",
|
||||
request.getEventId(), request.getTitle());
|
||||
|
||||
// TODO: 실제 API 호출 (현재는 Mock)
|
||||
String distributionId = "NAVER-" + UUID.randomUUID().toString();
|
||||
try {
|
||||
// 네이버 블로그에 포스팅
|
||||
String postUrl = naverBlogClient.postToBlog(request);
|
||||
String distributionId = "NAVER-" + UUID.randomUUID().toString();
|
||||
|
||||
return ChannelDistributionResult.builder()
|
||||
.channel(ChannelType.NAVER)
|
||||
.success(true)
|
||||
.distributionId(distributionId)
|
||||
.estimatedReach(2000) // 블로그 방문자 수 기반
|
||||
.build();
|
||||
log.info("Naver blog post created successfully: eventId={}, postUrl={}",
|
||||
request.getEventId(), postUrl);
|
||||
|
||||
return ChannelDistributionResult.builder()
|
||||
.channel(ChannelType.NAVER)
|
||||
.success(true)
|
||||
.distributionId(distributionId)
|
||||
.postUrl(postUrl)
|
||||
.estimatedReach(2000) // 블로그 방문자 수 기반
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to post to Naver blog: eventId={}, error={}",
|
||||
request.getEventId(), e.getMessage(), e);
|
||||
|
||||
return ChannelDistributionResult.builder()
|
||||
.channel(ChannelType.NAVER)
|
||||
.success(false)
|
||||
.errorMessage("Naver blog posting failed: " + e.getMessage())
|
||||
.estimatedReach(0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.kt.distribution.client;
|
||||
|
||||
import com.kt.distribution.dto.DistributionRequest;
|
||||
import com.microsoft.playwright.*;
|
||||
import com.microsoft.playwright.options.LoadState;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* Naver Blog Client using Playwright
|
||||
* 네이버 블로그 포스팅 자동화 클라이언트
|
||||
*
|
||||
* @author Backend Developer
|
||||
* @since 2025-10-29
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class NaverBlogClient {
|
||||
|
||||
@Value("${naver.blog.username:}")
|
||||
private String username;
|
||||
|
||||
@Value("${naver.blog.password:}")
|
||||
private String password;
|
||||
|
||||
@Value("${naver.blog.blog-id:}")
|
||||
private String blogId;
|
||||
|
||||
@Value("${naver.blog.headless:false}")
|
||||
private boolean headless;
|
||||
|
||||
@Value("${naver.blog.session-path:playwright-sessions}")
|
||||
private String sessionPath;
|
||||
|
||||
private Playwright playwright;
|
||||
private Browser browser;
|
||||
private BrowserContext context;
|
||||
|
||||
/**
|
||||
* Playwright 초기화
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
log.info("Initializing Playwright for Naver Blog");
|
||||
playwright = Playwright.create();
|
||||
|
||||
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
|
||||
.setHeadless(headless)
|
||||
.setSlowMo(100)); // 안정성을 위한 느린 실행
|
||||
|
||||
// 세션 디렉토리 생성
|
||||
File sessionDir = new File(sessionPath);
|
||||
if (!sessionDir.exists()) {
|
||||
sessionDir.mkdirs();
|
||||
log.info("Created session directory: {}", sessionPath);
|
||||
}
|
||||
|
||||
// 세션 파일 경로
|
||||
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
|
||||
|
||||
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
|
||||
if (Files.exists(sessionFilePath)) {
|
||||
log.info("Loading existing session from: {}", sessionFilePath);
|
||||
context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setStorageStatePath(sessionFilePath));
|
||||
} else {
|
||||
log.info("No existing session found, creating new context");
|
||||
context = browser.newContext();
|
||||
}
|
||||
|
||||
log.info("Playwright initialized successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize Playwright", e);
|
||||
throw new RuntimeException("Playwright initialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 네이버 블로그에 포스팅
|
||||
*
|
||||
* @param request DistributionRequest
|
||||
* @return 포스팅 URL
|
||||
* @throws Exception 포스팅 실패 시
|
||||
*/
|
||||
public String postToBlog(DistributionRequest request) throws Exception {
|
||||
Page page = null;
|
||||
try {
|
||||
page = context.newPage();
|
||||
// 타임아웃을 5분(300000ms)으로 설정
|
||||
page.setDefaultTimeout(300000);
|
||||
|
||||
// 로그인 확인 및 처리
|
||||
if (!isLoggedIn(page)) {
|
||||
login(page);
|
||||
}
|
||||
|
||||
// 블로그 글쓰기 페이지로 이동
|
||||
String writeUrl = String.format("https://blog.naver.com/%s/postwrite", blogId);
|
||||
page.navigate(writeUrl);
|
||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||
|
||||
|
||||
// 도움말 팝업이 있으면 닫기
|
||||
try {
|
||||
page.waitForTimeout(5000); // 충분히 대기 필요
|
||||
|
||||
Locator helpPanel = page.locator("[class*='help-panel']");
|
||||
|
||||
if (helpPanel.isVisible(new Locator.IsVisibleOptions().setTimeout(2000))) {
|
||||
log.debug("Help dialog detected, closing it");
|
||||
|
||||
// 팝업 안의 닫기 버튼 찾기
|
||||
Locator closeBtn = page.locator("button[class*='se-help-panel-close-button']");
|
||||
closeBtn.click();
|
||||
Thread.sleep(500);
|
||||
log.debug("Help dialog closed");
|
||||
} else{
|
||||
log.debug("--------------------- 도움말 없음");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("No help dialog found or already closed");
|
||||
}
|
||||
|
||||
// 제목 입력
|
||||
Locator titleInput = page.locator(".se-text-paragraph").first();
|
||||
titleInput.click();
|
||||
titleInput.pressSequentially(request.getTitle(), new Locator.PressSequentiallyOptions().setDelay(50));
|
||||
log.debug("Title entered: {}", request.getTitle());
|
||||
|
||||
// 본문 입력
|
||||
Locator editorInput = page.locator(".se-text-paragraph").nth(1);
|
||||
editorInput.click();
|
||||
titleInput.pressSequentially(request.getDescription(), new Locator.PressSequentiallyOptions().setDelay(50));
|
||||
log.debug("Content entered");
|
||||
|
||||
// 이미지가 있으면 업로드
|
||||
if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) {
|
||||
uploadImage(page, request.getImageUrl());
|
||||
}
|
||||
|
||||
// 발행 버튼 클릭
|
||||
page.locator("button[class*='publish_btn']").click();
|
||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||
page.locator("button[class*='confirm_btn']").click();
|
||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||
|
||||
page.waitForTimeout(5000); // 충분히 대기 필요
|
||||
|
||||
// 포스팅 URL 가져오기
|
||||
String postUrl = page.url();
|
||||
log.info("Post published successfully: {}", postUrl);
|
||||
|
||||
return postUrl;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to post to Naver blog: eventId={}, error={}",
|
||||
request.getEventId(), e.getMessage(), e);
|
||||
throw e;
|
||||
} finally {
|
||||
if (page != null) {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 상태 확인
|
||||
*
|
||||
* @param page Page
|
||||
* @return 로그인 여부
|
||||
*/
|
||||
private boolean isLoggedIn(Page page) {
|
||||
try {
|
||||
page.navigate("https://blog.naver.com");
|
||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||
|
||||
// 로그인 버튼이 보이지 않으면 로그인된 상태
|
||||
// ID 기반 선택자 사용으로 strict mode violation 방지
|
||||
return !page.locator("#gnb_login_button").isVisible();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to check login status", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 네이버 로그인 (수동 로그인 대기 방식)
|
||||
*
|
||||
* @param page Page
|
||||
* @throws Exception 로그인 실패 시
|
||||
*/
|
||||
private void login(Page page) throws Exception {
|
||||
try {
|
||||
log.info("Starting Naver manual login process");
|
||||
log.info("=================================================");
|
||||
log.info("Please login manually in the browser window");
|
||||
log.info("브라우저 창에서 수동으로 로그인해주세요");
|
||||
log.info("=================================================");
|
||||
|
||||
// 네이버 로그인 페이지로 이동
|
||||
page.navigate("https://nid.naver.com/nidlogin.login");
|
||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||
|
||||
// 사용자가 수동으로 로그인할 때까지 대기 (URL이 변경될 때까지)
|
||||
// 로그인 성공 시 URL이 nid.naver.com에서 벗어남
|
||||
log.info("Waiting for manual login... (Timeout: 30 seconds)");
|
||||
|
||||
try {
|
||||
// 30초 동안 URL이 nid.naver.com을 벗어날 때까지 대기
|
||||
page.waitForURL(url -> !url.contains("nid.naver.com"),
|
||||
new Page.WaitForURLOptions().setTimeout(30000));
|
||||
|
||||
log.info("Login URL changed, assuming login successful");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Login timeout or failed", e);
|
||||
throw new Exception("Manual login timeout or failed after 30 seconds");
|
||||
}
|
||||
|
||||
// 추가 안정화 대기
|
||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||
Thread.sleep(2000); // 2초 추가 대기
|
||||
|
||||
// 세션 저장
|
||||
context.storageState(new BrowserContext.StorageStateOptions()
|
||||
.setPath(Paths.get(sessionPath, "naver-blog-session.json")));
|
||||
|
||||
log.info("Naver manual login successful, session saved");
|
||||
log.info("Current URL: {}", page.url());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Naver manual login process failed", e);
|
||||
throw new Exception("Naver manual login failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 업로드
|
||||
*
|
||||
* @param page Page
|
||||
* @param imageUrl 이미지 URL
|
||||
*/
|
||||
private void uploadImage(Page page, String imageUrl) {
|
||||
try {
|
||||
log.debug("Uploading image: {}", imageUrl);
|
||||
|
||||
// 이미지 업로드 버튼 클릭
|
||||
page.locator("button[aria-label='사진']").click();
|
||||
|
||||
// URL로 이미지 추가 (실제 구현은 네이버 블로그 UI에 따라 조정 필요)
|
||||
// 여기서는 간단히 로그만 남김
|
||||
log.info("Image upload placeholder - URL: {}", imageUrl);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to upload image: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright 리소스 정리
|
||||
*/
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
try {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
if (browser != null) {
|
||||
browser.close();
|
||||
}
|
||||
if (playwright != null) {
|
||||
playwright.close();
|
||||
}
|
||||
log.info("Playwright resources cleaned up");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to cleanup Playwright resources", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동으로 브라우저 컨텍스트 새로고침
|
||||
* 장시간 사용 시 세션 만료 방지용
|
||||
*/
|
||||
public void refreshContext() {
|
||||
try {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
|
||||
// 세션 파일 경로
|
||||
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
|
||||
|
||||
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
|
||||
if (Files.exists(sessionFilePath)) {
|
||||
log.info("Refreshing context with existing session");
|
||||
context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setStorageStatePath(sessionFilePath));
|
||||
} else {
|
||||
log.info("Refreshing context without session");
|
||||
context = browser.newContext();
|
||||
}
|
||||
|
||||
log.info("Browser context refreshed");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to refresh context", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -32,6 +32,11 @@ public class ChannelDistributionResult {
|
||||
*/
|
||||
private String distributionId;
|
||||
|
||||
/**
|
||||
* 배포 URL (성공 시) - 실제 포스팅된 URL
|
||||
*/
|
||||
private String postUrl;
|
||||
|
||||
/**
|
||||
* 예상 노출 수 (성공 시)
|
||||
*/
|
||||
|
||||
+1
@@ -225,6 +225,7 @@ public class DistributionService {
|
||||
.channel(result.getChannel())
|
||||
.status(result.isSuccess() ? "COMPLETED" : "FAILED")
|
||||
.distributionId(result.getDistributionId())
|
||||
.postUrl(result.getPostUrl())
|
||||
.estimatedViews(result.getEstimatedReach())
|
||||
.eventId(eventId)
|
||||
.completedAt(completedAt)
|
||||
|
||||
@@ -129,7 +129,7 @@ naver:
|
||||
username: ${NAVER_BLOG_USERNAME:}
|
||||
password: ${NAVER_BLOG_PASSWORD:}
|
||||
blog-id: ${NAVER_BLOG_ID:}
|
||||
headless: ${NAVER_BLOG_HEADLESS:true}
|
||||
headless: ${NAVER_BLOG_HEADLESS:false}
|
||||
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
|
||||
|
||||
# Springdoc OpenAPI (Swagger)
|
||||
|
||||
Reference in New Issue
Block a user