네이버 블로그 ìž 배포 개발(ì이미지 없음)

This commit is contained in:
sunmingLee
2025-10-30 18:37:31 +09:00
parent be59934f78
commit ae8f540d46
9 changed files with 620 additions and 16 deletions
@@ -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);
}
}
}
@@ -32,6 +32,11 @@ public class ChannelDistributionResult {
*/
private String distributionId;
/**
* 배포 URL (성공 시) - 실제 포스팅된 URL
*/
private String postUrl;
/**
* 예상 노출 수 (성공 시)
*/
@@ -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)