Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@EnableAsync
@EnableScheduling
@Configuration
public class AsyncConfig implements AsyncConfigurer {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.net.URL;
import java.util.List;
import java.util.concurrent.Callable;
Expand All @@ -21,12 +23,19 @@
@Service
public class BlogRecipeThumbnailCrawlingService {

// 썸네일 크롤링은 URL 당 외부 HTTP 를 최대 2회 호출한다. 순차로 하면 매우 느려서 병렬로 처리한다.
private static final int CRAWL_POOL_SIZE = 32;
// 첫 검색이 썸네일 크롤링 때문에 지나치게 오래 블로킹되지 않도록 전체 대기 상한을 둔다.
private static final long CRAWL_TIMEOUT_SECONDS = 6L;
// 각 페이지 fetch 타임아웃(ms). Jsoup connect 의 기본값(30s)은 느린 URL 하나가 스레드를 오래 점유하므로 줄인다.
// 첫 검색 경로: 응답을 오래 막지 않도록 병렬 처리하되, 네이버 rate-limit 을 피하려 동시성은 과하지 않게.
// (개별 크롤이 ~0.2~0.5초라 12 동시성이면 50건도 수 초 내, 6초 예산 안에서 처리)
private static final int FIRST_SEARCH_POOL_SIZE = 12;
private static final long FIRST_SEARCH_TIMEOUT_SECONDS = 6L;
// 백필(스케줄러/수동) 경로: 배경 작업이라 네이버 부담을 줄이려 병렬 작게 + 대기 넉넉히.
private static final int BACKFILL_POOL_SIZE = 8;
private static final long BACKFILL_TIMEOUT_SECONDS = 120L;

// 각 페이지 fetch 타임아웃(ms). Jsoup 기본(30s)은 느린 URL 하나가 스레드를 오래 점유하므로 줄인다.
private static final int FETCH_TIMEOUT_MS = 3000;
// 타깃 재시도: "페이지 fetch 실패(네트워크/타임아웃)" 에만 재시도. og:image 부재 등은 재시도해도 소용없어 즉시 포기.
private static final int MAX_FETCH_ATTEMPTS = 3;
private static final long RETRY_BACKOFF_MS = 700L;

private final BlogRecipeRepository blogRecipeRepository;

Expand All @@ -35,25 +44,48 @@ public BlogRecipeThumbnailCrawlingService(BlogRecipeRepository blogRecipeReposit
}

/**
* 신규 저장된 블로그 레시피들의 썸네일을 크롤링해 채운다.
* 신규 저장된 블로그 레시피들의 썸네일을 크롤링해 채운다(첫 검색 경로).
* 첫 검색에서도 썸네일이 보이도록 동기로 처리하되, 병렬 + 전체 타임아웃으로 대기 시간을 제한한다.
* 타임아웃 내에 못 끝낸 소수는 썸네일이 빈 채로 남는다(크롤링 실패도 빈 문자열).
*/
public void saveThumbnails(List<BlogRecipe> blogRecipes) {
crawlAndSave(blogRecipes, FIRST_SEARCH_POOL_SIZE, FIRST_SEARCH_TIMEOUT_SECONDS);
}

/**
* 썸네일이 비어 있는(크롤 실패로 미채움) 블로그 레시피를 한 번에 limit 개까지 다시 크롤링해 채운다.
* (스케줄러 백필 경로. 여러 회차에 걸쳐 백로그를 소진한다.)
*
* @return 처리 대상 건수
*/
public int retryEmptyThumbnails(int limit) {

List<BlogRecipe> targets = blogRecipeRepository.findEmptyThumbnails(PageRequest.of(0, limit));

if (targets.isEmpty()) {
return 0;
}

log.info("blog thumbnail retry start - targets={}", targets.size());
crawlAndSave(targets, BACKFILL_POOL_SIZE, BACKFILL_TIMEOUT_SECONDS);
log.info("blog thumbnail retry done - targets={}", targets.size());
return targets.size();
}

private void crawlAndSave(List<BlogRecipe> blogRecipes, int poolSize, long timeoutSeconds) {

if (blogRecipes == null || blogRecipes.isEmpty()) {
return;
}

ExecutorService pool = Executors.newFixedThreadPool(Math.min(CRAWL_POOL_SIZE, blogRecipes.size()));
ExecutorService pool = Executors.newFixedThreadPool(Math.min(poolSize, blogRecipes.size()));
try {
List<Callable<Void>> tasks = blogRecipes.stream()
.map(blogRecipe -> (Callable<Void>) () -> {
blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl()));
blogRecipe.changeThumbnail(crawlThumbnail(blogRecipe.getBlogUrl()));
return null;
})
.collect(Collectors.toList());
pool.invokeAll(tasks, CRAWL_TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.invokeAll(tasks, timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("blog thumbnail crawling interrupted");
Expand All @@ -64,55 +96,68 @@ public void saveThumbnails(List<BlogRecipe> blogRecipes) {
blogRecipeRepository.saveAll(blogRecipes);
}

public String getBlogThumbnailUrl(String blogUrl) {
/**
* 타깃 재시도 크롤: 페이지 fetch 실패(IOException/타임아웃)에만 최대 {@link #MAX_FETCH_ATTEMPTS}회 재시도한다.
* 페이지는 왔는데 og:image 가 없는 경우(영구적 상황)는 빈 문자열을 그대로 반환하고 재시도하지 않는다.
*/
public String crawlThumbnail(String blogUrl) {

for (int attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt++) {
try {
return fetchThumbnail(blogUrl);
} catch (IOException e) {
if (attempt == MAX_FETCH_ATTEMPTS) {
log.debug("thumbnail fetch failed after {} attempts: {} ({})", attempt, blogUrl, e.getMessage());
return "";
}
sleepQuietly(RETRY_BACKOFF_MS);
}
}
return "";
}

private String fetchThumbnail(String blogUrl) throws IOException {

if (blogUrl.contains("naver")) {
return getNaverBlogThumbnailUrl(blogUrl);
return fetchNaverThumbnail(blogUrl);
} else if (blogUrl.contains("tistory")) {
return getTistoryBlogThumbnailUrl(blogUrl);
} else {
return "";
return fetchTistoryThumbnail(blogUrl);
}
return "";
}

private String getNaverBlogThumbnailUrl(String blogUrl) {

try {

URL url = new URL(blogUrl);
Document doc = Jsoup.parse(url, FETCH_TIMEOUT_MS);

Elements iframes = doc.select("iframe#mainFrame");
String src = iframes.attr("src");
private String fetchNaverThumbnail(String blogUrl) throws IOException {

String url2 = "http://blog.naver.com" + src;
Document doc2 = Jsoup.connect(url2).timeout(FETCH_TIMEOUT_MS).get();
Document doc = Jsoup.parse(new URL(blogUrl), FETCH_TIMEOUT_MS);

return doc2.select("meta[property=og:image]").get(0).attr("content");
} catch (Exception e) {
return "";
String src = doc.select("iframe#mainFrame").attr("src");
if (src.isBlank()) {
return ""; // mainFrame 이 없으면 구조상 추출 불가 → 재시도 무의미
}

Document doc2 = Jsoup.connect("http://blog.naver.com" + src).timeout(FETCH_TIMEOUT_MS).get();
Elements og = doc2.select("meta[property=og:image]");
return og.isEmpty() ? "" : og.get(0).attr("content");
}

private String getTistoryBlogThumbnailUrl(String blogUrl) {
private String fetchTistoryThumbnail(String blogUrl) throws IOException {

try {
Document doc = Jsoup.connect(blogUrl).timeout(FETCH_TIMEOUT_MS).get();

Document doc = Jsoup.connect(blogUrl).timeout(FETCH_TIMEOUT_MS).get();

Elements imageLinks = doc.getElementsByTag("img");
String thumbnailUrl = null;
for (Element image : imageLinks) {
String temp = image.attr("src");
if (!temp.contains("admin")) {
thumbnailUrl = temp;
break;
}
for (Element image : doc.getElementsByTag("img")) {
String src = image.attr("src");
if (!src.contains("admin")) {
return src;
}
}
return "";
}

return thumbnailUrl;
} catch (Exception e) {
return "";
private void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.recipe.app.src.recipe.application.blog;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
* 썸네일이 비어 있는(크롤 실패로 미채움) 블로그 레시피를 주기적으로 재크롤한다.
*
* - 시간 제한 없이 빈 것 전체 대상(백로그 포함). 한 번에 BATCH_SIZE 개씩 처리하며 여러 회에 걸쳐 소진.
* - 대부분은 일시 실패라 다음 회차에 채워지고, 영구 실패(삭제/비공개 등 소수)만 매번 재시도된다.
* - 6시간마다 실행. 대량 외부 크롤이라 동시성은 서비스에서 낮게(8) 잡아 네이버 부담을 줄인다.
* - 단일 인스턴스 전제(로컬 포함 항상 동작).
*/
@Slf4j
@Component
public class BlogThumbnailRetryScheduler {

private static final int BATCH_SIZE = 300;

private final BlogRecipeThumbnailCrawlingService blogRecipeThumbnailCrawlingService;

public BlogThumbnailRetryScheduler(BlogRecipeThumbnailCrawlingService blogRecipeThumbnailCrawlingService) {
this.blogRecipeThumbnailCrawlingService = blogRecipeThumbnailCrawlingService;
}

// 6시간마다 (00/06/12/18시)
@Scheduled(cron = "0 0 0/6 * * *")
public void retryEmptyThumbnails() {
int processed = blogRecipeThumbnailCrawlingService.retryEmptyThumbnails(BATCH_SIZE);
log.info("scheduled blog thumbnail retry processed={}", processed);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.recipe.app.src.recipe.infra.blog;

import com.recipe.app.src.recipe.domain.blog.BlogRecipe;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
Expand All @@ -10,4 +12,8 @@
public interface BlogRecipeRepository extends JpaRepository<BlogRecipe, Long>, BlogRecipeCustomRepository {

List<BlogRecipe> findByBlogUrlIn(List<String> blogUrls);

// 썸네일이 비어 있는(크롤 실패로 미채움) 블로그 레시피 — 스케줄러가 배치로 재크롤(백로그 포함)
@Query("SELECT b FROM BlogRecipe b WHERE b.blogThumbnailImgUrl IS NULL OR b.blogThumbnailImgUrl = '' ORDER BY b.blogRecipeId DESC")
List<BlogRecipe> findEmptyThumbnails(Pageable pageable);
}
Loading