실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 4편
목차
- 들어가며
- 상세 페이지의 HTML부터 확인해봅시다
- 먼저 책 제목을 가져와봅시다
- 태그 안의 글자를 가져오는 text()
- 가격도 같은 방법으로 가져올 수 있습니다
- 재고 정보를 가져와봅시다
- trim()으로 불필요한 공백을 없애봅시다
- 별점은 조금 다른 곳에 숨어 있습니다
- 문자열을 공백으로 나눠봅시다
- 숫자로 바꾸는 것은 조금 뒤로 미뤄두겠습니다
- 흩어진 값을 하나의 Book으로 묶어봅시다
- 상세 페이지를 파싱하는 함수를 만들어봅시다
- main()에서 상세 정보를 출력해봅시다
- 실행 결과를 확인해봅시다
- 그런데 unwrap()이 너무 많지 않나요?
- 지금까지의 프로그램은 어디까지 왔을까요?
- 이번 편의 완성 코드
- 5편 예고: 가져온 데이터를 사용할 수 있는 형태로 바꿔봅시다
- 이번 편에서 배운 내용
들어가며
지난 3편에서는 목록 페이지에서 찾은 상대 URL을 완전한 주소로 만든 뒤, 각 책의 상세 페이지에 직접 HTTP 요청을 보내는 것까지 진행했습니다.
아직 안 보셨다면 먼저 3편을 보고 오시는 것을 추천드립니다.
지난 편에서 최종적으로 작성한 코드는 첫 페이지에 있는 20권의 책을 하나씩 순회하며 각 상세 페이지 HTML을 가져왔습니다.
목록 페이지
↓
책 링크 20개 추출
↓
상세 URL 생성
↓
각 상세 페이지에 GET 요청
↓
상세 HTML 수신
이제 웹페이지 안쪽까지 들어가는 데 성공했으니, 드디어 처음부터 우리가 원했던 데이터를 실제로 가져올 차례입니다.
이번 편에서는 상세 페이지에 있는 다음 네 가지 정보를 추출해보겠습니다.
책 제목
가격
재고
별점
그리고 각각 따로 흩어져 있는 값들을 Book이라는 하나의 구조체에 담아보겠습니다.
스크래퍼가 본격적으로 데이터를 수집하는 프로그램으로 변하는 시점입니다.
상세 페이지의 HTML부터 확인해봅시다
우선 첫 번째 책의 상세 페이지를 다시 열어보겠습니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
페이지에는 책 제목, 가격, 재고, 상품 설명, 제품 정보 등 다양한 데이터가 표시되어 있습니다.
브라우저의 개발자 도구를 이용해 HTML을 살펴보면 우리가 필요한 부분은 대략 다음과 같은 형태입니다.
<div class="col-sm-6 product_main">
<h1>A Light in the Attic</h1>
<p class="price_color">£51.77</p>
<p class="instock availability">
<i class="icon-ok"></i>
In stock (22 available)
</p>
<p class="star-rating Three">
...
</p>
</div>
지난 2편에서 했던 작업과 크게 다르지 않습니다.
HTML을 파싱하고,
Html::parse_document()
원하는 CSS 선택자를 만들고,
Selector::parse()
그 선택자에 해당하는 요소를 찾으면 됩니다.
다만 이번에는 하나의 <a> 태그에서 두 개의 속성을 가져오는 것이 아니라, 서로 다른 여러 HTML 요소를 찾아야 합니다.
먼저 책 제목을 가져와봅시다
상세 페이지의 책 제목은 <h1> 태그 안에 있습니다.
<h1>A Light in the Attic</h1>
따라서 선택자는 아주 간단합니다.
h1
Rust에서는 다음처럼 작성합니다.
let title_selector =
Selector::parse("h1").unwrap();
그리고 HTML 문서에서 해당 요소를 찾습니다.
let title_element = document
.select(&title_selector)
.next()
.unwrap();
지난 2편에서는 같은 선택자에 해당하는 책이 여러 권 있었기 때문에 for문을 사용했습니다.
이번에는 상세 페이지 하나에 책 제목이 하나만 있으므로 첫 번째 요소만 가져오면 됩니다.
이때 사용하는 것이 next() 메서드입니다.
.next()
select()가 선택자와 일치하는 요소들을 차례대로 꺼낼 수 있는 반복자를 반환한다면, next()는 그중 첫 번째 요소 하나를 가져옵니다.
흐름으로 보면 다음과 같습니다.
document.select(...)
↓
조건에 맞는 요소들을 찾음
↓
.next()
↓
첫 번째 요소 하나를 가져옴
이제 <h1> 태그 안의 실제 문자열을 가져와야 합니다.
태그 안의 글자를 가져오는 text()
지난 2편에서는 책 제목을 <a> 태그의 title 속성에서 가져왔습니다.
.attr("title")
이번에는 상황이 조금 다릅니다.
<h1>A Light in the Attic</h1>
우리가 원하는 값은 속성이 아니라 태그 사이에 있는 글자입니다.
이럴 때는 text() 메서드를 사용할 수 있습니다.
let title = title_element
.text()
.collect::<String>();
결과는 다음과 같습니다.
A Light in the Attic
여기서 text()만 호출하면 바로 하나의 String이 반환되는 것은 아닙니다.
HTML 요소 안에는 여러 개의 텍스트 조각이 존재할 수 있기 때문에, text()는 텍스트들을 차례대로 가져올 수 있는 형태로 제공합니다. 조금 더 명확히 말하자면, 요소 안에 포함된 텍스트 노드들을 차례대로 순회할 수 있는 반복자를 반환합니다.
그래서 다음의
.collect::<String>()
을 이용해 하나의 문자열로 합쳐주는 것입니다.
지난 편까지는 주로 HTML의 속성값을 가져왔다면,
.attr("href")
.attr("title")
이번에는 HTML 태그 안의 텍스트 값도 가져올 수 있게 되었습니다.
가격도 같은 방법으로 가져올 수 있습니다
가격 HTML은 다음과 같습니다.
<p class="price_color">£51.77</p>
CSS 선택자는 다음과 같이 만들 수 있습니다.
p.price_color
Rust 코드로 작성해보겠습니다.
let price_selector =
Selector::parse("p.price_color").unwrap();
let price = document
.select(&price_selector)
.next()
.unwrap()
.text()
.collect::<String>();
출력해보면 다음과 같은 값을 얻을 수 있습니다.
£51.77
제목과 과정이 거의 똑같죠?
선택자 생성
↓
요소 검색
↓
첫 번째 요소 선택
↓
텍스트 추출
웹 스크래핑을 하다 보면 결국 이런 패턴을 굉장히 많이 반복하게 됩니다.
처음에는 코드가 낯설지만 몇 번 반복하고 나면 HTML을 보는 순간 어느 정도 코드가 머릿속에 그려지기 시작합니다.
재고 정보를 가져와봅시다
재고 정보는 다음 HTML에 들어 있습니다.
<p class="instock availability">
<i class="icon-ok"></i>
In stock (22 available)
</p>
이번에는 클래스가 두 개입니다.
instock
availability
HTML에서는 공백으로 구분된 여러 클래스가 하나의 요소에 동시에 붙을 수 있습니다.
따라서 다음과 같은 CSS 선택자를 사용할 수 있습니다.
p.instock.availability
여기서 클래스 사이에는 공백을 넣지 않습니다.
.instock.availability
라는 표현은,
instock클래스와availability클래스를 동시에 가진 요소
를 의미합니다.
반대로 이렇게 공백을 넣으면 의미가 달라집니다.
.instock .availability
이 경우에는 instock 요소 안에 있는 availability 요소를 찾는다는 뜻입니다.
점 하나와 공백 하나가 꽤 큰 차이를 만드는 셈입니다.
Rust 코드로 작성하면 다음과 같습니다.
let stock_selector =
Selector::parse("p.instock.availability").unwrap();
let stock = document
.select(&stock_selector)
.next()
.unwrap()
.text()
.collect::<String>();
그런데 출력해보면 원래 의도한 것과 조금 다른 결과가 나올 수 있습니다.
In stock (22 available)
HTML 안에 들어 있는 줄바꿈과 공백까지 함께 가져오기 때문입니다.
trim()으로 불필요한 공백을 없애봅시다
이럴 때 사용할 수 있는 것이 trim() 메서드입니다.
let stock = document
.select(&stock_selector)
.next()
.unwrap()
.text()
.collect::<String>();
let stock = stock.trim();
이제 결과가 깔끔해집니다.
In stock (22 available)
trim()은 문자열 앞뒤의 불필요한 공백이나 줄바꿈을 제거합니다.
실제 웹페이지의 HTML은 사람이 읽기 좋도록 줄바꿈과 들여쓰기가 많이 들어가 있기 때문에 스크래핑을 하다 보면 꽤 자주 사용하게 됩니다.
한 번에 작성할 수도 있습니다.
let stock = document
.select(&stock_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
마지막에 다시 to_string()을 사용한 이유는 지난 2편에서 배운 것과 같습니다.
trim()의 결과는 기존 문자열 일부를 빌려온 &str 형태이므로, 앞으로 구조체에 저장하기 위해 독립적인 String으로 만들어주는 것입니다.
별점은 조금 다른 곳에 숨어 있습니다
이제 별점을 가져와보겠습니다.
별점 HTML은 다음과 같은 형태입니다.
<p class="star-rating Three">
...
</p>
재미있는 점은 태그 안의 텍스트를 아무리 읽어도 Three라는 값이 나오지 않는다는 것입니다.
별점 정보는 다음처럼 클래스 이름에 들어 있습니다.
star-rating Three
따라서 이번에는 text()가 아니라 다시 attr()을 이용합니다.
먼저 별점 요소를 선택합니다.
let rating_selector =
Selector::parse("p.star-rating").unwrap();
그리고 class 속성을 가져옵니다.
let rating_class = document
.select(&rating_selector)
.next()
.unwrap()
.value()
.attr("class")
.unwrap();
결과는 다음과 같습니다.
star-rating Three
우리가 원하는 것은 뒤의 Three입니다.
문자열을 공백으로 나눠봅시다
Rust에서는 split_whitespace() 메서드를 이용해 문자열을 공백 기준으로 나눌 수 있습니다.
let rating = rating_class
.split_whitespace()
.nth(1)
.unwrap();
다음 문자열이,
star-rating Three
이렇게 나뉘게 됩니다.
0번째 → star-rating
1번째 → Three
nth(1)은 두 번째 값을 가져옵니다.
Rust는 숫자를 셀 때 0부터 시작하기 때문에,
nth(0) → star-rating
nth(1) → Three
가 됩니다.
따라서 최종적으로 얻는 별점은,
Three
입니다.
숫자로 바꾸는 것은 조금 뒤로 미뤄두겠습니다
지금 별점은 다음처럼 영어 단어로 되어 있습니다.
One
Two
Three
Four
Five
물론 이것을 다음처럼 숫자로 바꿀 수도 있습니다.
One → 1
Two → 2
Three → 3
Four → 4
Five → 5
하지만 지금은 HTML에서 데이터를 정확하게 가져오는 것이 우선입니다.
데이터를 가져오는 과정과 데이터를 원하는 형태로 가공하는 과정을 한 번에 섞기 시작하면 코드가 금방 복잡해집니다.
일단 이번 편에서는 원본 사이트에 있는 값을 그대로 가져오고, 이후 데이터를 정리하는 단계에서 숫자로 변환해보겠습니다.
흩어진 값을 하나의 Book으로 묶어봅시다
지금까지 네 개의 값을 가져왔습니다.
title
price
stock
rating
이 상태로 계속 변수를 늘려갈 수도 있지만 앞으로 상품 설명, 이미지, 카테고리까지 추가한다면 관리하기가 점점 어려워집니다.
그래서 서로 관련된 값을 하나의 구조체에 담아보겠습니다.
struct Book {
title: String,
price: String,
stock: String,
rating: String,
}
지난 2편에서 만들었던 BookLink와 비슷합니다.
다만 BookLink가 목록 페이지에서 얻을 수 있는 최소한의 정보였다면, Book은 상세 페이지에서 가져온 실제 책 정보를 표현합니다.
BookLink
├── title
└── url
Book
├── title
├── price
├── stock
└── rating
여기서 웹사이트의 HTML 구조를 Rust의 데이터 구조로 조금씩 옮겨오고 있다는 느낌이 든다면, 굉장히 잘 따라오고 계신 겁니다:)
상세 페이지를 파싱하는 함수를 만들어봅시다
이제 지금까지 작성한 코드를 하나의 함수로 정리하겠습니다.
함수 이름은 parse_book_detail()로 하겠습니다.
fn parse_book_detail(html: &str) -> Book {
let document = Html::parse_document(html);
let title_selector =
Selector::parse("h1").unwrap();
let price_selector =
Selector::parse("p.price_color").unwrap();
let stock_selector =
Selector::parse("p.instock.availability").unwrap();
let rating_selector =
Selector::parse("p.star-rating").unwrap();
let title = document
.select(&title_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let price = document
.select(&price_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let stock = document
.select(&stock_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let rating = document
.select(&rating_selector)
.next()
.unwrap()
.value()
.attr("class")
.unwrap()
.split_whitespace()
.nth(1)
.unwrap()
.to_string();
Book {
title,
price,
stock,
rating,
}
}
코드가 제법 길어졌지만 하는 일은 단순합니다.
상세 HTML 입력
↓
HTML 파싱
↓
제목 추출
↓
가격 추출
↓
재고 추출
↓
별점 추출
↓
Book 생성
그리고 마지막의
Book {
title,
price,
stock,
rating,
}
부분에서 네 개의 값을 하나의 Book 객체로 묶어서 반환합니다.
main()에서 상세 정보를 출력해봅시다
이제 지난 편의 main() 함수에 parse_book_detail()을 연결해보겠습니다.
for book_link in books {
let detail_url =
make_detail_url(&book_link.);
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
let book =
parse_book_detail(&detail_html);
println!("제목: {}", book.title);
println!("가격: {}", book.price);
println!("재고: {}", book.stock);
println!("별점: {}", book.rating);
println!();
}
book_link는 목록 페이지에서 가져온 링크 정보이고,
BookLink
book은 상세 페이지에서 새롭게 만들어진 책 정보입니다.
Book
이제 프로그램에는 두 종류의 데이터가 존재합니다.
목록 페이지
BookLink
↓
상세 페이지 URL 생성
↓
HTTP 요청
↓
상세 HTML
↓
Book
웹페이지의 흐름과 Rust 코드의 데이터 흐름이 거의 그대로 대응되고 있습니다.
실행 결과를 확인해봅시다
터미널에서 프로그램을 실행합니다.
cargo run
정상적으로 동작한다면 다음과 비슷한 결과가 출력됩니다.
제목: A Light in the Attic
가격: £51.77
재고: In stock (22 available)
별점: Three
제목: Tipping the Velvet
가격: £53.74
재고: In stock (20 available)
별점: One
...
우리가 처음 연재를 시작할 때 원했던 모습에 상당히 가까워졌습니다.
이제 HTML 크기를 출력하던 프로그램이 아니라 실제 웹페이지의 데이터를 읽어서 구조화된 정보로 보여주고 있습니다.
그런데 unwrap()이 너무 많지 않나요?
코드를 보다 보면 슬슬 신경 쓰이는 것이 하나 있습니다.
.unwrap()
오늘 작성한 코드 곳곳에 들어 있습니다.
예를 들어 제목을 찾을 때도 사용했고,
.next()
.unwrap()
별점의 클래스 값을 가져올 때도 사용했습니다.
.attr("class")
.unwrap()
unwrap()은 값이 있다는 것을 전제로 실제 값을 꺼내는 아주 편리한 방법입니다.
하지만 만약 값이 없다면 프로그램은 그 자리에서 종료됩니다.
예를 들어 어느 날 사이트의 HTML이 다음처럼 바뀌었다고 가정해보겠습니다.
<h2>A Light in the Attic</h2>
우리는 여전히,
Selector::parse("h1")
을 찾고 있기 때문에 아무것도 발견하지 못합니다.
그러면,
.next()
의 결과는 None이 되고,
.unwrap()
을 호출하는 순간 프로그램이 panic을 발생합니다.
지금처럼 구조를 정확히 알고 있는 연습용 사이트에서는 unwrap()을 사용해 코드를 단순하게 유지하는 것도 괜찮습니다.
하지만 실제 사이트를 상대로 장시간 동작하는 프로그램이라면 이야기가 달라집니다.
HTML 구조는 바뀔 수 있고, 특정 데이터만 빠질 수도 있으며, 예상하지 못한 페이지가 응답으로 돌아올 수도 있습니다.
이 문제는 이후 편에서 따로 제대로 다뤄보겠습니다.
지금까지의 프로그램은 어디까지 왔을까요?
처음에 만들었던 전체 구조를 다시 살펴보겠습니다.
도서 목록 페이지 요청
↓
각 도서의 상세 페이지 주소 수집
↓
상세 페이지 요청
↓
제목·가격·평점·재고·설명 추출
↓
Markdown 파일 생성
현재 상태는 이렇습니다.
[완료] 도서 목록 페이지 요청
↓
[완료] 각 도서의 상세 페이지 주소 수집
↓
[완료] 상세 페이지 요청
↓
[진행 중] 제목·가격·평점·재고·설명 추출
↓
[예정] Markdown 파일 생성
오늘 제목, 가격, 재고, 별점을 가져왔으니 이제 상세 페이지에 남은 중요한 데이터는 상품 설명과 이미지 정도입니다.
하지만 곧바로 Markdown 파일을 만드는 것보다 먼저 데이터를 조금 더 깔끔하게 정리할 필요가 있습니다.
현재 가격은,
£51.77
재고는,
In stock (22 available)
별점은,
Three
형태입니다.
사람이 읽기에는 아무 문제가 없지만 프로그램이 다루기 좋은 데이터라고 하기에는 조금 애매합니다.
예를 들어 가격을 비교하려면 £를 제거하고 숫자로 바꾸는 편이 좋고, 재고 역시 문장 전체보다 22라는 숫자만 가지고 있는 것이 편리합니다.
스크래핑과 데이터 정제가 서로 다른 작업이라는 이유입니다.
이번 편의 완성 코드
지금까지 작성한 코드를 하나로 합쳐보겠습니다.
use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{Html, Selector};
const TARGET_URL: &str = "https://books.toscrape.com/";
struct BookLink {
title: String,
url: String,
}
struct Book {
title: String,
price: String,
stock: String,
rating: String,
}
async fn fetch_html(
client: &Client,
url: &str,
) -> Result<String, reqwest::Error> {
let response = client
.get(url)
.header(
USER_AGENT,
"rust-book-scraper/0.1"
)
.send()
.await?
.error_for_status()?;
response.text().await
}
fn parse_book_links(html: &str) -> Vec<BookLink> {
let document = Html::parse_document(html);
let selector =
Selector::parse("article.product_pod h3 a")
.unwrap();
let mut books = Vec::new();
for element in document.select(&selector) {
let title = element
.value()
.attr("title")
.unwrap()
.to_string();
let url = element
.value()
.attr("href")
.unwrap()
.to_string();
books.push(BookLink {
title,
url,
});
}
books
}
fn make_detail_url(path: &str) -> String {
format!("{}{}", TARGET_URL, path)
}
fn parse_book_detail(html: &str) -> Book {
let document = Html::parse_document(html);
let title_selector =
Selector::parse("h1").unwrap();
let price_selector =
Selector::parse("p.price_color").unwrap();
let stock_selector =
Selector::parse("p.instock.availability").unwrap();
let rating_selector =
Selector::parse("p.star-rating").unwrap();
let title = document
.select(&title_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let price = document
.select(&price_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let stock = document
.select(&stock_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let rating = document
.select(&rating_selector)
.next()
.unwrap()
.value()
.attr("class")
.unwrap()
.split_whitespace()
.nth(1)
.unwrap()
.to_string();
Book {
title,
price,
stock,
rating,
}
}
#[::]
async fn main() -> Result<(), reqwest::Error> {
let client = Client::new();
let html = fetch_html(
&client,
TARGET_URL,
)
.await?;
let books = parse_book_links(&html);
println!("찾은 책: {}권", books.len());
println!();
for book_link in books {
let detail_url =
make_detail_url(&book_link.);
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
let book =
parse_book_detail(&detail_html);
println!("제목: {}", book.title);
println!("가격: {}", book.price);
println!("재고: {}", book.stock);
println!("별점: {}", book.rating);
println!();
}
Ok(())
}
이제 프로그램을 실행하면 첫 페이지의 책 20권을 하나씩 방문하면서 각 제목, 가격, 재고, 별점을 출력합니다.
처음에는 HTML을 문자열로 가져오는 프로그램이었는데, 이제는 웹페이지 안에 들어 있는 의미 있는 데이터를 Rust의 구조체로 옮겨 담는 프로그램이 되었습니다.
5편 예고: 가져온 데이터를 사용할 수 있는 형태로 바꿔봅시다
현재 프로그램은 데이터를 가져오는 데는 성공했습니다.
하지만 값의 형태를 다시 보면,
가격: £51.77
재고: In stock (22 available)
별점: Three
아직 웹사이트가 보여주는 문자열 그대로입니다.
다음 편에서는 이 데이터를 프로그램이 사용하기 좋은 형태로 정리해보겠습니다.
예를 들면,
£51.77
↓
51.77
In stock (22 available)
↓
22
Three
↓
3
와 같이 바꿀 수 있습니다.
그리고 Book 구조체도 다음과 같은 방향으로 발전시켜보겠습니다.
struct Book {
title: String,
price: f64,
stock: u32,
rating: u8,
}
지금까지는 웹페이지에서 문자열을 가져오는 것이 중심이었다면, 다음 편부터는 가져온 문자열을 데이터로 만드는 과정에 들어갑니다.
사람에게 £51.77은 가격이라는 것이 한눈에 보이지만, 프로그램 입장에서는 아직 글자에 불과합니다.
5편에서는 이 글자들에게 제대로 된 자료형을 입혀보겠습니다.
이번 편에서 배운 내용
이번 편에서는 다음 내용을 구현했습니다.
- 상세 페이지 HTML 구조 확인
h1을 이용한 책 제목 추출text()를 이용한 HTML 텍스트 추출collect::<String>()을 이용한 문자열 결합- 여러 클래스를 가진 요소를 CSS 선택자로 찾는 방법
trim()을 이용한 불필요한 공백 제거class속성에서 별점 정보 추출split_whitespace()와nth()사용- 상세 데이터를 담기 위한
Book구조체 생성 parse_book_detail()함수 작성- 목록 페이지의
BookLink와 상세 페이지의Book연결 unwrap()사용 시 주의할 점
다음 편에서는 이번에 가져온 문자열 데이터를 숫자로 변환하고 조금 더 다루기 좋은 형태로 정리해보겠습니다.