[Spring] Jsoup 라이브러리를 이용한 웹 크롤링 Issue 다루기
Jsoup 라이브러리를 이용한 웹 크롤링
🔥Issue 1
뉴닉 이라는 웹사이트를 클론코딩하는 프로젝트를 FE-BE와 진행하면서 로그인/회원가입 구현 스켈레톤 코딩 후
메인페이지 CRUD와 웹 크롤링으로 자료를 띄우는 기능을 맡았다.
처음에 뉴닉 웹 사이트에서 웹 크롤링을 할 수 있을 줄 알고 코드를 거의 다 작성해가는데 문제가 발생했다.
웹 크롤링이 가능한지 판단할 때 웹 사이트/robots.txt
를 url 검색에 쳐봐야 한다는 걸 뒤늦게 깨달았다.
아래 사진처럼 https://newneek.co/robots.txt
이라고 검색했을 때 모든 User에 대해서 User-agent: *Disallow:
허락하지 않는다.
즉, 웹 크롤링이 불가능하다는 것을 알 수 있다.
해결 방법(Issue 1)
1. Jsoup 라이브러리 설치
build.gradle
implementation 'org.jsoup:jsoup:1.14.3'
- Jsoup은 HTML 문서에서 데이터를 추출하고 조작하기 위한 편리한 API를 제공한다
- CSS Selector 문법을 사용하여 특정 HTML 요소를 선택해서 필요한 정보를 가져올 수 있게 한다
- Http Request를 사용하는 라이브러리여서 정적 페이지만 파싱할 수 있다
2. url 변경해서 콘솔에 찍어본다
크롤링이 가능한 네이버 사이트 검색창으로 url을 변경하고 키워드 검색 하듯이 조회하면서 뉴스 title, content, imageUrl, date, category를 불러온다
1
2
private void scrapeNaverNews(String query) {
String url = "https://search.naver.com/search.naver?query=" + query + "&where=news";
지금은 scrapeNaverNews("정치");
정치가 String query
로 들어가고 있으므로 정치와 연관된 뉴스 기사들이 크롤링 된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@SpringBootApplication
public class CloneCodingUnicornApplication implements CommandLineRunner{
public static void main(String[] args) {
SpringApplication.run(CloneCodingUnicornApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
scrapeNaverNews("정치");
}
private void scrapeNaverNews(String query) {
String url = "https://search.naver.com/search.naver?query=" + query + "&where=news";
try {
Document document = Jsoup.connect(url).get();
Elements newsArticles = document.select("div.news_area");
for (Element article : newsArticles) {
String title = article.select("a.news_tit").text();
String link = article.select("a.news_tit").attr("href");
String content = article.select("div.articleBody").text();
String imageUrl = article.select("img").attr("src");
String date = article.select("span.info").text();
String category = article.select("a.info.press").text();
System.out.println("Title: " + title);
System.out.println("Content: " + content);
System.out.println("ImageUrl: " + imageUrl);
System.out.println("Date: " + date);
System.out.println("Category: " + category);
System.out.println("Link: " + link + "\n");
}
} catch (IOException | java.io.IOException e) {
e.printStackTrace();
}
}
}
🔥Issue 2
title을 제외하고 content, image, date, category는 원하는 값으로 올바른 형식에 따라 콘솔에 제대로 찍히지 않는다
image는 추후 다룬다
content, date, category(=query) 형식에 맞게 출력하려면 코드의 어떤 부분을 수정해야 할까?
해결 방법(Issue 2)
html 구조를 살핀다
- 위의 사진처럼 html 구조를 뜯어 봤을 때 news_area 중심으로 우리가 필요한 데이터 title, content, imageUrl, date가 있는 것을 볼 수 있다
- 따라서, scrapeNaverNews 메서드 내의 url을 div.news_area를 기준으로 추출하는 것으로 한다
- 그에 맞게 selector도 변경해서 아래와 같이 코드를 재작성 한다
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
try { Document document = Jsoup.connect(url).get(); Elements newsArticles = document.select("div.news_area"); for (Element article : newsArticles) { String title = article.select("a.news_tit").text(); String link = article.select("a.news_tit").attr("href"); String content = article.select("div.news_dsc").text(); String imageUrl = article.select("img").attr("src"); String date = article.select("span.info").text(); String category = query; System.out.println("Title: " + title); System.out.println("Content: " + content); System.out.println("Image: " + imageUrl); System.out.println("Date: " + date); System.out.println("Category: " + category); System.out.println("Link: " + link + "\n"); }
- category는 현재 query 와 동일하기 때문에 변경한다
- class명 앞에는
.
을 붙여서 사용한다
결과
Application.java에 있던 파일을 Controller, Service, Application으로 분리한 작업
CloneCodingUnicornApplication
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@SpringBootApplication
public class CloneCodingUnicornApplication implements CommandLineRunner{
private PostService postService;
public CloneCodingUnicornApplication(PostService postService) {
this.postService = postService;
}
public static void main(String[] args) {
SpringApplication.run(CloneCodingUnicornApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
postService.scrapeNaverNews("정치");
postService.scrapeNaverNews("경제");
postService.scrapeNaverNews("세계");
postService.scrapeNaverNews("테크");
postService.scrapeNaverNews("노동");
postService.scrapeNaverNews("환경");
postService.scrapeNaverNews("인권");
postService.scrapeNaverNews("사회");
postService.scrapeNaverNews("문화");
postService.scrapeNaverNews("라이프");
}
}
PostController.java
1
2
3
4
5
@GetMapping("/scrapeNaverNews")
public String scrapeNaverNews(@RequestParam String query) throws IOException {
postService.scrapeNaverNews(query);
return "Scraping in progress. Check console for details.";
}
PostService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Slf4j
@Service
public class PostService {
private final PostRepository postRepository;
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
public void scrapeNaverNews(String query) throws IOException {
String url = "https://search.naver.com/search.naver?query=" + query + "&where=news";
List<Post> scrapedPosts = new ArrayList<>();
try {
Document document = Jsoup.connect(url).get();
Elements newsArticles = document.select("div.news_area");
for (Element article : newsArticles) {
String title = article.select("a.news_tit").text();
String contents = article.select("div.news_dsc").text();
String imageUrl = article.select("div.news_contents > a.dsc_thumb > img.thumb").attr("src");
String date = article.select("span.info").text();
String categoryText = query;
Category category = mapCategory(categoryText);
Post savedPost = postRepository.save(new Post(title, contents, imageUrl, date, category));
scrapedPosts.add(savedPost);
List<PostResponseDto> savedPostsResponseDto = new ArrayList<>();
for (Post scrapedPost : scrapedPosts) {
Post savePost = postRepository.save(scrapedPost);
savedPostsResponseDto.add(new PostResponseDto(savePost));
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private Category mapCategory(String categoryText) {
for (Category category : Category.values()) {
if (categoryText.equals(category.name())) {
return category;
}
}
throw new IllegalArgumentException("존재하지 않는 카테고리 입니다." + categoryText);
}
}
🔥Issue 3
- 네이버 검색창에서 카테고리를 입력했을 때 나오는 뉴스 리스트 중 사진을 url로 받아올 때
imageUrl 데이터 형식이 GIF 이미지의 실제 Base64 인코딩 바이너리 데이터로 나온다 - 아래 사진처럼 인코딩된 이미지 데이터가 다 동일한 데이터로 들어옴
해결 방법(Issue 3)
- 네이버 뉴스 검색창에서 검색한 category로 뉴스 리스트로 받게 되면 각 뉴스마다 태그 값이 다르기 때문에
- 태그값이 동일한 한 메인페이지 내에서 웹크롤링을 진행하고
웹사이트 주소/robots.txt
를 주소창에 검색했을 떄 Allow 되는 사이트를 사용한다
- 서울 경제 페이지에서 모든 User에 대해 Allow 돼있는 것을 알 수 있다
- 따라서, 웹 크롤링 url과 태그를 바꿔서 재작성한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for(Category newsDetailsLinkPair : todaysNewsLinkList){
Document newsDetails_doc = Jsoup.connect(newsDetailsLinkPair.getLink()).get();
String newsTitle = newsDetails_doc.select("#v-left-scroll-in > div.article_head > h1").text();
String newsSummary = newsDetails_doc.select("#v-left-scroll-in > div.article_con > div.con_left > div.article_summary").text();
Elements imageUrl = newsDetails_doc.select("div.article_view img");
Elements texts = newsDetails_doc.select("div.article_view");
Elements newsInfo = newsDetails_doc.select("div.article_info");
Element dateSpan = newsInfo.select("span.url_txt").get(1);
String date = dateSpan.text().substring(3,13);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd");
LocalDate newsDate = LocalDate.parse(date, formatter);
String firstImageUrl = "";
if(imageUrl.first() != null) {
firstImageUrl = imageUrl.first().absUrl("src"); // 첫 번째 이미지 URL을 가져옵니다.
}
This post is licensed under CC BY 4.0 by the author.