Skip to content

Commit 43ae78e

Browse files
committed
feat/OPS-356 : 네이버 블로그 크롤러 생성
1 parent c7af82d commit 43ae78e

File tree

6 files changed

+204
-41
lines changed

6 files changed

+204
-41
lines changed

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,36 +22,36 @@ public class AiPrompt {
2222

2323
// 내용 요약, 태그 추출, 카테고리 선정 프롬프트
2424
public static final String SUMMARY_TAG_CATEGORY = """
25-
너는 뉴스, 블로그 등 내용 요약 및 분류 AI야. 아래의 규칙에 따라 답변해.
26-
27-
[규칙]
28-
1. 주어진 content를 50자 이상 100자 이하로 간단히 요약해라.
29-
2. 아래 Category 목록 중에서 content와 가장 적절한 카테고리 하나를 정확히 선택해라.
30-
- POLITICS("정치")
31-
- ECONOMY("경제")
32-
- SOCIETY("사회")
33-
- IT("IT")
34-
- SCIENCE("과학")
35-
- CULTURE("문화")
36-
- SPORTS("스포츠")
37-
- ENVIRONMENT("환경")
38-
- HISTORY("역사")
39-
- WORLD("세계")
40-
3. 내가 제공하는 태그 목록을 참고해서, content와 관련된 태그를 3~5개 생성해라.
41-
- 제공된 태그와 중복 가능하다.
42-
- 필요하면 새로운 태그를 만들어도 된다.
43-
4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라.
44-
- 해당 정보가 없으면 null말고 무조건 빈 문자열로 출력해줘라.
45-
46-
[출력 JSON 형식]
47-
{
48-
"summary": "내용 요약 (50~100자)",
49-
"category": "선택된 카테고리 ENUM 이름",
50-
"tags": ["태그1", "태그2", "태그3", ...]
51-
}
52-
53-
[입력 데이터]
54-
content: %s
55-
existingTags: %s
25+
너는 뉴스, 블로그 등 내용 요약 및 분류 AI야. 아래의 규칙에 따라 답변해.
26+
27+
[규칙]
28+
1. 주어진 content를 50자 이상 100자 이하로 간단히 요약해라.
29+
2. 아래 Category 목록 중에서 content와 가장 적절한 카테고리 하나를 정확히 선택해라.
30+
- POLITICS("정치")
31+
- ECONOMY("경제")
32+
- SOCIETY("사회")
33+
- IT("IT")
34+
- SCIENCE("과학")
35+
- CULTURE("문화")
36+
- SPORTS("스포츠")
37+
- ENVIRONMENT("환경")
38+
- HISTORY("역사")
39+
- WORLD("세계")
40+
3. 내가 제공하는 태그 목록을 참고해서, content와 관련된 태그를 3~5개 생성해라.
41+
- 제공된 태그와 중복 가능하다.
42+
- 필요하면 새로운 태그를 만들어도 된다.
43+
4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라.
44+
- 해당 정보가 없으면 null말고 무조건 빈 문자열로 출력해줘라.
45+
46+
[출력 JSON 형식]
47+
{
48+
"summary": "내용 요약 (50~100자)",
49+
"category": "선택된 카테고리 ENUM 이름",
50+
"tags": ["태그1", "태그2", "태그3", ...]
51+
}
52+
53+
[입력 데이터]
54+
content: %s
55+
existingTags: %s
5656
""";
5757
}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import org.jsoup.nodes.Document;
44
import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult;
55

6+
import java.io.IOException;
67
import java.time.LocalDate;
78

89
public interface Crawler {
910
boolean supports(String domain);
10-
CrawlerResult<?> extract(Document doc);
11+
CrawlerResult<?> extract(Document doc) throws IOException;
1112
LocalDate transLocalDate(String rawDate);
1213
}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public boolean supports(String url) {
2020
@Override
2121
public CrawlerResult<?> extract(Document doc) {
2222
// 불필요한 태그 제거
23-
doc.select("script, style, noscript, iframe, nav, header, footer, form, aside, meta, link").remove();
23+
doc.select("script, style, noscript, meta, link").remove();
2424

2525
// 본문만 가져오기 (HTML)
2626
String cleanHtml = doc.body().html();
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package org.tuna.zoopzoop.backend.domain.datasource.crawler.service;
2+
3+
import org.jsoup.Jsoup;
4+
import org.jsoup.nodes.Document;
5+
import org.jsoup.nodes.Element;
6+
import org.jsoup.select.Elements;
7+
import org.springframework.core.Ordered;
8+
import org.springframework.core.annotation.Order;
9+
import org.springframework.stereotype.Component;
10+
import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult;
11+
import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto;
12+
13+
import java.io.IOException;
14+
import java.time.LocalDate;
15+
import java.time.LocalDateTime;
16+
import java.time.format.DateTimeFormatter;
17+
18+
@Component
19+
@Order(Ordered.HIGHEST_PRECEDENCE)
20+
public class NaverBlogCrawler implements Crawler {
21+
private static final SupportedDomain DOMAIN = SupportedDomain.NAVERBLOG;
22+
private static final DateTimeFormatter NAVERBLOG_FORMATTER =
23+
DateTimeFormatter.ofPattern("yyyy. M. d. HH:mm");
24+
25+
@Override
26+
public boolean supports(String domain) {
27+
return domain.contains(DOMAIN.getDomain());
28+
}
29+
30+
@Override
31+
public CrawlerResult<?> extract(Document doc) throws IOException {
32+
/*
33+
블로그 본문은 <iframe id="mainFrame"> 안에 로드되므로
34+
먼저 메인 페이지를 가져온 뒤 iframe의 src를 추출하여
35+
해당 URL로 다시 connect 해야 실제 본문 내용을 크롤링할 수 있다.
36+
*/
37+
Element iframe = doc.selectFirst("iframe#mainFrame");
38+
String iframeUrl = iframe.absUrl("src");
39+
40+
Document iframeDoc = Jsoup.connect(iframeUrl)
41+
.userAgent("Mozilla/5.0") // 크롤링 차단 방지를 위해 user-agent 설정 권장
42+
.timeout(10 * 1000) // 타임아웃 (10초)
43+
.get();
44+
45+
// 제목
46+
Element titleSpans = iframeDoc.selectFirst("div.se-module.se-module-text.se-title-text");
47+
String title = titleSpans.text();
48+
49+
// 작성일자
50+
String publishedAt = iframeDoc.selectFirst("span.se_publishDate.pcol2").text();
51+
LocalDateTime rawDate = LocalDateTime.parse(publishedAt, DateTimeFormatter.ofPattern("yyyy. M. d. HH:mm"));
52+
LocalDate dataCreatedDate = rawDate.toLocalDate();
53+
54+
// 내용
55+
Elements spans = iframeDoc.select(".se-main-container span");
56+
StringBuilder sb = new StringBuilder();
57+
for (Element span : spans) {
58+
sb.append(span.text()); // 태그 안 텍스트만
59+
}
60+
String content = sb.toString();
61+
62+
// 썸네일 이미지 URL
63+
Element img = iframeDoc.select("div.se-main-container img").first();
64+
65+
String imageUrl = "";
66+
if (img != null) {
67+
if (!img.attr("data-lazy-src").isEmpty()) {
68+
imageUrl = img.attr("data-lazy-src");
69+
}
70+
}
71+
72+
// 출처
73+
String source = "네이버 블로그";
74+
75+
return new CrawlerResult<>(
76+
CrawlerResult.CrawlerType.SPECIFIC,
77+
new SpecificSiteDto(title, dataCreatedDate, content, imageUrl, source)
78+
);
79+
}
80+
81+
@Override
82+
public LocalDate transLocalDate(String rawDate) {
83+
LocalDateTime dateTime = LocalDateTime.parse(rawDate, NAVERBLOG_FORMATTER);
84+
return dateTime.toLocalDate(); // 시간 버리고 날짜만
85+
}
86+
}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/SupportedDomain.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package org.tuna.zoopzoop.backend.domain.datasource.crawler.service;
22

33
public enum SupportedDomain {
4-
NAVERNEWS("n.news.naver.com");
4+
NAVERNEWS("n.news.naver.com"),
5+
NAVERBLOG("blog.naver.com");
56

67
private final String domain;
78

src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto;
1717
import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService;
1818
import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.GenericCrawler;
19+
import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.NaverBlogCrawler;
1920
import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.NaverNewsCrawler;
2021

2122
import java.io.IOException;
2223
import java.time.LocalDate;
24+
import java.time.LocalDateTime;
2325
import java.time.format.DateTimeFormatter;
2426
import java.util.List;
2527

@@ -40,11 +42,14 @@ public class CrawlerManagerServiceTest {
4042
@Mock
4143
private GenericCrawler genericCrawler;
4244

45+
@Mock
46+
private NaverBlogCrawler naverBlogCrawler;
47+
4348
@BeforeEach
4449
void setUp() {
4550
// 직접 리스트를 생성자에 주입
4651
crawlerManagerService = new CrawlerManagerService(
47-
List.of(naverNewsCrawler, genericCrawler)
52+
List.of(naverNewsCrawler, naverBlogCrawler, genericCrawler)
4853
);
4954
}
5055

@@ -82,7 +87,7 @@ void FetchHtmlUrlTest() throws IOException {
8287
}
8388

8489
@Test
85-
void NaverCrawlerTest() throws IOException {
90+
void NaverNewsCrawlerTest() throws IOException {
8691
// given
8792
String url = "https://n.news.naver.com/mnews/article/008/0005254080"; // 원하는 URL 넣기
8893

@@ -128,17 +133,88 @@ void NaverCrawlerTest() throws IOException {
128133
assertThat(naverDoc.source()).isEqualTo(sources);
129134
}
130135

136+
@Test
137+
void NaverBlogCrawlerTest() throws IOException {
138+
// given
139+
String url = "https://blog.naver.com/rainbow-brain/223387331292"; // 원하는 URL 넣기
140+
// String url = "https://blog.naver.com/smhrd_official/223078242394";
141+
142+
Document doc = Jsoup.connect(url)
143+
.userAgent("Mozilla/5.0") // 크롤링 차단 방지를 위해 user-agent 설정 권장
144+
.timeout(10 * 1000) // 타임아웃 (10초)
145+
.get();
146+
147+
Element iframe = doc.selectFirst("iframe#mainFrame");
148+
String iframeUrl = iframe.absUrl("src");
149+
150+
Document iframeDoc = Jsoup.connect(iframeUrl)
151+
.userAgent("Mozilla/5.0") // 크롤링 차단 방지를 위해 user-agent 설정 권장
152+
.timeout(10 * 1000) // 타임아웃 (10초)
153+
.get();
154+
155+
// 제목
156+
Element titleSpans = iframeDoc.selectFirst("div.se-module.se-module-text.se-title-text");
157+
String title = titleSpans.text();
158+
System.out.println(title);
159+
160+
// 작성일자
161+
String publishedAt = iframeDoc.selectFirst("span.se_publishDate.pcol2").text();
162+
LocalDateTime rawDate = LocalDateTime.parse(publishedAt, DateTimeFormatter.ofPattern("yyyy. M. d. HH:mm"));
163+
LocalDate dataCreatedDate = rawDate.toLocalDate();
164+
System.out.println(dataCreatedDate);
165+
166+
// 내용
167+
Elements spans = iframeDoc.select(".se-main-container span");
168+
StringBuilder sb = new StringBuilder();
169+
for (Element span : spans) {
170+
sb.append(span.text()); // 태그 안 텍스트만
171+
}
172+
String content = sb.toString();
173+
System.out.println(content);
174+
175+
// 썸네일 이미지 URL
176+
Element img = iframeDoc.select("div.se-main-container img").first();
177+
178+
String imageUrl = "";
179+
if (img != null) {
180+
if (!img.attr("data-lazy-src").isEmpty()) {
181+
imageUrl = img.attr("data-lazy-src");
182+
}
183+
}
184+
System.out.println(imageUrl);
185+
186+
// 출처
187+
String source = "네이버 블로그";
188+
189+
190+
when(naverBlogCrawler.supports(url)).thenReturn(true);
191+
given(naverBlogCrawler.extract(any(Document.class))).willCallRealMethod(); // 실제 메소드 실행
192+
// given(naverBlogCrawler.transLocalDate(any(String.class))).willCallRealMethod();
193+
194+
// when
195+
CrawlerResult<?> result = crawlerManagerService.extractContent(url);
196+
SpecificSiteDto naverDoc = (SpecificSiteDto) result.data();
197+
198+
// then
199+
assertThat(result.type()).isEqualTo(CrawlerResult.CrawlerType.SPECIFIC);
200+
assertThat(naverDoc.title()).isEqualTo(title);
201+
assertThat(naverDoc.content()).isEqualTo(content);
202+
assertThat(naverDoc.dataCreatedDate()).isEqualTo(dataCreatedDate);
203+
assertThat(naverDoc.imageUrl()).isEqualTo(imageUrl);
204+
assertThat(naverDoc.source()).isEqualTo(source);
205+
}
206+
131207
@Test
132208
void GenericCrawlerTest() throws IOException {
133209
// given
134-
String url = "https://bcuts.tistory.com/421"; // 원하는 URL 넣기
210+
String url = "https://blog.naver.com/rainbow-brain/223387331292"; // 원하는 URL 넣기
135211

136212
Document doc = Jsoup.connect(url)
137213
.userAgent("Mozilla/5.0") // 크롤링 차단 방지를 위해 user-agent 설정 권장
138214
.timeout(10 * 1000) // 타임아웃 (10초)
139215
.get();
140216

141-
doc.select("script, style, noscript, iframe, nav, header, footer, form, aside, meta, link").remove();
217+
doc.select("script, style, noscript, meta, link").remove();
142218

143219
String cleanHtml = doc.body().html();
144220

@@ -149,10 +225,9 @@ void GenericCrawlerTest() throws IOException {
149225
CrawlerResult<?> result = crawlerManagerService.extractContent(url);
150226
UnspecificSiteDto genericDoc = (UnspecificSiteDto) result.data();
151227

152-
System.out.println(genericDoc.rawHtml());
153-
154228
// then
155229
assertThat(result.type()).isEqualTo(CrawlerResult.CrawlerType.UNSPECIFIC);
156-
assertThat(genericDoc.rawHtml()).contains("<!-- warp / 테마 변경시 thema_xxx 변경 -->");
230+
// assertThat(genericDoc.rawHtml()).contains("<html");
231+
assertThat(genericDoc.rawHtml()).isEqualTo(cleanHtml);
157232
}
158233
}

0 commit comments

Comments
 (0)