Jsoup 과 크롤링 팁
크롤링은 Selenium 으로 하는거 아닌가요?
최근, 크롤링 작업을 하게 된 일이 있었다. 근데, Selenium
을 사용하지 않고 Jsoup
이라는 라이브러리를 사용하고 있어서 의아심이 있었다. 예전에 비트코인 매매봇, 수강 신청 매크로 등을 만들때 매번 셀레니움을 사용했기 때문이다.
Jsoup
https://github.com/jhy/jsoup
Jsoup 은 HTML 과 XML 을 작업하게 해주는 라이브러리이다. DOM 에 관련된 API, CSS 및 XPath 를 사용해 선택할 수 있게 해준다.
VS Selenium
Jsoup 은 HTTP 요청을 날려서 가져온다. 즉, 서버로부터 응답받은 정적 HTML 데이터만 작업 가능하다는 의미다.
최신 웹사이트들은, 빠른 반응성을 위해 사용자가 콘텐츠를 서버에 요청했을 때 클라이언트 사이드 랜더링(CSR, Client-Side Rendering) 을 사용한다.
- 서버는 데이터만 브라우저에 전송하고
- 브라우저에서는 이 데이터를 가지고 화면 랜더링
CSR은 브라우저에서 화면을 그려준다는 동적인 특성 때문에 서버에 데이터를 요청하는 HTTP Request를 사용하면 실제 화면에 그려진 데이터는 수집할 수 없게 된다.
Selenium 은 WebDriver를 이용하므로 동적인 데이터들을 수집할 수 있다.
WebDriver
브라우저를 프로그래밍적으로 제어할 수 있도록 설계된 자동화 인터페이스이다. ( 직접 열고,닫고,클릭, 네트워크 이벤트 등 제어 )
- 브라우저 마다 각각의 Driver 를 가진다.
- 컴퓨터 내 존재하는 브라우저와 버전이 일치해야 한다.
사용해보기
단순, 성능 측정을 하려 했는데 매우 귀찮은 과정을 거쳤다. 설치하니 인식 못하고, 맥북이라 애플리케이션 지정했는데 안되고 등등등… ( 이 역시도 Selenium 의 단점 아닐까… )
1
2
3
4
5
6
7
8
WebDriverManager.chromedriver().setup();
final WebDriver driver = new ChromeDriver();
try {
driver.get("https://www.google.com/");
} finally {
driver.quit();
}
설치되어 있는 Google Chrome 과 버전이 일치해야 하는데 이를 맞추기 어려울 수 있다. ( Driver 를 제공해주지 않거나, 버전 업데이트가 일어나거나 ) -> 그렇기에, 버전을 자동으로 맞춰주는 'io.github.bonigarcia:webdrivermanager:5.5.3'
의존성을 사용했다.
버전이 있는지 확인하고, 없다면 자동으로 다운로드 + 경로 지정을 해준다. ( 로컬 캐시를 통해 반복적인 다운로드는 방지 )
이 부분 역시도 불필요한 시간을 소요한다.
1
Setup Time Taken: 347ms
단순, https://www.google.com/
에 요청을 보내보면?
1
2
3
4
...
Document document = Jsoup.connect(url).get();
driver.get(url);
...
1
2
3
Jsoup Time Taken: 795ms
Selenium Time Taken: 1649ms
이와같이 시간의 차이가 난다. +
Chrome 을 실행시키는 것이므로 CPU 와 메모리가 더 소모가 된다. ( 확인할때는, 3MB 정도 발생 )
결론
Selenium 은 왠만하면 사용할 필요가 없다고 생각한다. ( 시간적인 면 + 리소스적인 면에서 성능적 차이가 나기 때문에 )
자기가 크롤링 해야하는게
- 단계별 작업을 필요로 하는지 ( 로그인을 하고, 어떤 상호작용을 해야하며… )
- 정적인 데이터가 아닌, 동적인 데이터를 필요로 하는지
가 아니라면, 정적 크롤링을 사용하도록 하자.
이제부터 Jsoup 을 사용하는 간단한 방법들과 팁을 정리하고 끝내겠다. 생각보다 CookBook 을 제공을 너무 잘해줘서 이 부분만 읽는다면 매우 편하게 작업할 수 있을 것이다.
Document 가져오기
1
Jsoup.connect(url)
URL 을 통해 연결할 Connection 을 만든다.
1
2
3
public interface Connection {
...
}
Timeout
,Header
,Cookie
등 우리가 흔히 아는 모든 HTTP 요소들을 가능하게 해준다.
1
2
3
4
5
6
7
@Override
public Document get() throws IOException {
req.method(Method.GET);
execute();
Validate.notNull(res);
return res.parse();
}
이와같이 메소드를 호출하면 요청을 보내고, Document
를 받아온다.
이미 가져왔다면?
1
2
String html = "<html><head><title>Example</title></head><body><p>Hello, World!</p></body></html>";
Document document = Jsoup.parse(html);
Jsoup.parse
를 사용하면 된다.
select
와 같은 예시가 있을때?
개발자 도구를 통해 들어가서 Elements
부분이 우리가 사용할 수 있는 요소들이다. ( 물론, CSR 을 통해 생성된 동적인 요소들인지는 확인해야 한다. )
1
document.html()
을 통해 확인가능하다.
Jsoup 은 CSS Selector 를 제공해주기 때문에 이를 사용해 DOM 의 요소들을 매우 편리하게 추출할 수 있다.
- 기본 선택자 (
tag
,.class
,#id
) - 계층 선택자
- 속성 선택자
- 상태 선택자 ( Jsoup 이 지원해주는 요소 ) -
div:contains(Hello)
계층 선택자에서 흔히 실수하는 요소가 있는데
- ancestor descendant : 특정 조상의 모든 자손 선택
- parent > child : 특정 부모의 바로 아래 자식 선택 이 두개의 차이점을 주의해야 한다.
HTML 문서들은 트리 구조로 표현이 된다. ( 각 태그가 트리의 노드로 변환 )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static Elements select(Evaluator evaluator, Element root) {
return Collector.collect(evaluator, root);
}
public static Elements collect (Evaluator eval, Element root) {
eval.reset();
return root.stream()
.filter(eval.asPredicate(root))
.collect(Collectors.toCollection(Elements::new));
}
public Predicate<Element> asPredicate(Element root) {
return element -> matches(root, element);
}
// 이는 구현체 ( 매 요소마다 다름 )
public boolean matches(Element root, Element element) {
return element.hasAttr(key) && value.equalsIgnoreCase(element.attr(key).trim());
}
<meta property="product:sale_price:amount" content="84000">
가 있으면?
1
2
var element = document.selectFirst("meta[property='product:sale_price:amount']")
return element.attr("content") // 84000
를 통해 meta property
를 찾고, 그 내부 요소를 추출한다.
1
var elements = document.select("div.xans-product-additional #prdDetail img")
div
중 xans-product-additional
클래스를 가지는 -> prdDetail
라는 ID 를 가지는 요소의 모든 자손 중 img
들만 추출한다.
간단한 팁
최대한 단순하게
해당 링크에서 상세내용이 필요해 Selector 를 복사해보면? -> #infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table
가 나온다.
document.select("#infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table > tbody")
를 통해서 가져올 수 있지만, .xans-product > table > tbody
로도 가능하다. 두 개의 성능 차이는?
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
public static void main(final String[] args) {
TimeUtil.measureExecutionTime("jsoup deep", () -> {
final var baseUrl = "https://riverstone.co.kr";
final var document = getDocument("https://riverstone.co.kr/category/women/25/?page=3");
final var documents = parseDocument(document, "ul.prdList a")
.stream()
.map(element -> element.attr("href"))
.map(url -> getDocument(url, baseUrl))
.toList();
final var result1 = findFirst(documents);
// final var result2 = findSecond(documents);
});
}
private static List<String> findFirst(final List<Document> documents) {
return documents.stream()
.map(document -> document.select(".xans-product > table > tbody"))
.map(element -> Optional.ofNullable(element)
.map(Elements::text)
.orElse(""))
.toList();
}
private static List<String> findSecond(final List<Document> documents) {
return documents.stream()
.map(document -> document.select("#infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table > tbody"))
.map(element -> Optional.ofNullable(element)
.map(Elements::text)
.orElse(""))
.toList();
}
캐싱 및 추가적인 요소들을 피하기 위해 의도적으로 페이지네이션을 통해 가져오게 했다.
1
2
// .xans-product > table > tbody - 51291ms
// #infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table > tbody - 50122ms
놀랍게도 큰 차이가 없다. 이는 DOM 트리 탐색 방식 때문이다. 해당 내용은 자세히는 다루지 않는다. ( 주제에 벗어난 부분 ) 모던 JavaScript 문서 해당 부분을 더 참고하면 좋을거 같다.
- DOM 은 트리 형태 계층적, 요소를 찾기 위해 계층을 따라 위 아래 이동 가능하다. - 관련 없는 부분 검색할 필요 없음.
- 고유 ID 를 기반으로 요소를 빠르게 찾도록 최적화 - HashMap 과 유사하게 O(1) 기대
- 브라우저가 잘못된
html
들은 DOM 탐색 및 조작을 효율적으로 처리하기 위해 자동으로 교정해준다.
이런 요소들을 기반으로 시간의 차이가 없게 만든다.
그럼에도, 필요한 요소들만 적용해서 크롤링을 해야 한다. 자세하게 나타낼수록, 변경에 민감해진다.
#infoArea_fixed
가#infoArea
로 변경이 된다면? -> 해당 크롤링은 변경이 되어야 한다.div.xans-element-.xans-product.xans-product-detaildesign
중xans-product-detaildesign
이 필요가 없어진다면? -> 해당 크롤링은 변경이 되어야 한다.
브라우저가 자동으로 제공해주는 Selector
가 아니라 자신이 직접 검색해서 필요한 요소들만 구성을 하자.
특히, 단순
Copy Selector
를 하면#prdDetail > div > div > div > div > div > img:nth-child(1)
이와 같이 요소들의 모든 선택자들을 가져오는 경우가 있다. 이때,div > ... > div
는 필요없을 수 있다. 어차피,#prdDetail
내img
들을 가져오게 하는게 핵심이므로
변하지 않게
썸네일용 사진을 가져오려고 하면?
#prdDetail > img:nth-child(1)
와 같이 가져온다.
nth-child
는 매우 가변적인 요소이다. img 중 n번째 자식
라는 의미이기 때문이다. 이 역시도, DOM 의 구조가 변하거나 웹 사이트 개발자들이 이미지 순서를 변경하면 침범을 받는다.
대부분의 상품성 사이트들은 검색 엔진 최적화를 위해 다양한 요소들을 제공한다.
- descritpion,keywords,author
- og:title,og:type,og:url ( Open Graph Protocol )
- naver,google site vertification
meta
를 통해 검색하거나, head
부분에 보면 매우 다양한 정보들을 제공해준다. 우리가 사용하려는 썸네일은? og:image
를 통해서도 같은 사진이 제공이 된다.
본문 내용에서 추출하는 것
보다 검색 엔진을 위해 제공해주는 것
이 더 변하지 않고, 항상 제공될 거라 기대할 수 있다.
이와 같은 맥락으로 자신이 지금 크롤링 하는 부분이 얼마나 변화에 민감할지, 고정으로 제공해줄지 등을 판단하는 것 역시도 매우 좋은 요소가 될 것이다.
당연한 robots
너무나 당연하지만 robots.txt
는 항상 잘 살펴보자. 웹 크롤러에게 특정 부분에 대한 접근을 허용하거나 차단하는 지침을 제공해준다.
- 특정 크롤러 (
User-Agent
) 를 차단하게 해주거나 - Github 는 User-Agent 를 비운채 요청을 보내면4xx
를 던진다. - 특정 경로를 접근하지 못하게 하거나 -
/admin, /api
- SEO 가 불필요한 접근을 하지 않거나
의 요소들로 이루어진다.
특히, 허용하지 않는 요소들을 무분별하게 크롤링해 상업적 이용을 하면 처벌이 될수도 있다. ### 형사는 무죄, 민사는 “10억 배상”…데이터 크롤링 어디까지 되나 ( 유명한 야놀자 vs 여기어때의 크롤링 법적 공방 )
함수화
이건 크롤링보다 사소한 꿀팁이지만
1
2
3
4
5
6
7
8
@Component
class ThumbnailImageInMeta : ThumbnailImage {
override fun parseFrom(document: Document, domain: String): String {
val element = document.selectFirst("meta[property='og:image']")
return element?.attr("content")?:throw IllegalStateException("Not Exist ThumbnailImage In Meta")
}
}
자주 사용한다면, @Component
화 해서 이를 주입해서 사용을 하게하자. 이는 크롤링 하려는 요소의 성격에 따라 다르지만, 비슷한 양상을 가진다면 선언하고 주입을 받아 코드 중복을 방지하자.
어차피, 크롤링은 기존 서버와 분리된 경우가 많으며 + 정해진 시간 or 규칙적으로 되는 경우가 많다. ( 메모리 및 성능에도 크게 구애 받지 않는다. )
1
2
3
private val crawler = Crawler(
ThumbnailImageInMeta(),
)
와 같이 단위 테스트를 할때는? -> 그냥 생성을 할 수 있으므로 Spring 의존성을 벗어나도 상관없다.
물론, 성급하게 코드를 추상화 및 공통화 하지 말고 적절히 된다면 도입하자.
1
2
3
4
abstract class Cafe24Crawler(
...
)
class SampleCrawler():Cafe24Crawler
이런식의 추상 클래스
: 구현 클래스
로 코드 중복 감소 역시도 가능하다.
결론
크롤링도 최대한 빠르고,쉽게 접근해 구현하면 가능하다.
하지만
- 크롤링적으로도 변경에 민감하지 않게
- 코드적으로 변경에 민감하지 않게
이 두가지를 잘 생각해서 크롤링을 만들어나가자.