실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 6편
목차
- 들어가며
- 상품 설명은 어디에 있을까요?
- 바로 다음 요소를 찾는 CSS 선택자
- Rust에서 상품 설명 가져오기
- 이번에는 책 표지 이미지를 찾아봅시다
- 상대 URL 문제가 다시 돌아왔습니다
- url 라이브러리를 추가해봅시다
- Url::join()으로 상대 URL 합치기
- 이미지 URL 변환도 함수로 만들어봅시다
- 그런데 왜 TARGET_URL이 아니라 상세 페이지 주소를 기준으로 할까요?
- Book 구조체에 데이터를 추가해봅시다
- parse_book_detail()에 URL도 전달해봅시다
- 설명과 이미지까지 함께 파싱해봅시다
- 수정된 main() 함수
- 지금까지 만든 Book을 한 번 살펴볼까요?
- BookLink의 제목은 이제 필요할까요?
- 이번 편의 완성 코드
- 이제 수집할 데이터는 거의 다 모았습니다
- 7편 예고: 책 한 권을 Markdown 파일로 만들어봅시다
- 이번 편에서 배운 내용
들어가며
지난 5편에서는 웹페이지에서 문자열로 가져온 가격, 재고, 별점을 실제 프로그램에서 활용하기 좋은 숫자 자료형으로 변환했습니다.
아직 안 보셨다면 먼저 5편을 보고 오시는 것을 추천드립니다.
현재 우리가 만든 Book 구조체는 다음과 같습니다.
struct Book {
title: String,
price: f64,
stock: u32,
rating: u8,
}
처음에는 HTML 전체를 문자열로 가져오는 것부터 시작했는데, 어느새 책 한 권을 Rust의 데이터로 표현할 수 있게 되었습니다.
하지만 아직 책이라고 하기에는 조금 허전합니다.
우리가 일반적인 온라인 서점의 상품 페이지를 생각해보면 제목과 가격만큼이나 중요한 정보가 있습니다.
바로 상품 설명과 책 표지 이미지입니다.
이번 편에서는 상세 페이지에서 다음 두 가지 데이터를 추가로 가져와보겠습니다.
상품 설명
표지 이미지 URL
여기서 상품 설명은 지금까지 배웠던 CSS 선택자를 조금 더 응용하면 가져올 수 있습니다.
이미지는 조금 다른 문제가 하나 있습니다.
HTML에서 가져온 이미지 주소가 완전한 URL이 아니라,
../../media/cache/2c/da/example.jpg
와 같은 상대 URL이기 때문입니다.
3편에서 상대 URL을 다룰 때 이런 경우에는 문자열을 단순하게 붙이는 것보다 URL 전용 도구를 사용하는 것이 안전하다고 이야기했었죠.
그 과정도 이번 편에서 함께 다뤄보겠습니다.
상품 설명은 어디에 있을까요?
먼저 우리가 계속 사용하고 있는 첫 번째 책의 상세 페이지를 살펴보겠습니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
개발자 도구에서 상품 설명 부분을 살펴보면 대략 다음과 같은 구조를 확인할 수 있습니다.
<div id="product_description" class="sub-header">
<h2>Product Description</h2>
</div>
<p>
It's hard to imagine a world without
A Light in the Attic...
</p>
조금 특이하죠?
우리가 실제로 원하는 설명은,
<p>...</p>
안에 들어 있지만 이 <p> 태그 자체에는 class도 없고 id도 없습니다.
지금까지는 이런 식으로 원하는 요소에 직접 이름표가 붙어 있었습니다.
<p class="price_color">
혹은,
<p class="instock availability">
그래서 CSS 선택자를 만들기도 쉬웠습니다.
하지만 이번 설명 영역의 <p>에는 우리가 바로 사용할 만한 이름표가 없습니다.
그렇다고 방법이 없는 것은 아닙니다.
바로 앞에 아주 좋은 표지판이 하나 있기 때문입니다.
<div id="product_description">
바로 다음 요소를 찾는 CSS 선택자
CSS 선택자에서는 어떤 요소의 바로 다음 형제 요소를 선택할 수 있습니다.
이때 사용하는 것이 +입니다.
다음과 같은 HTML이 있다고 해보겠습니다.
<h2>제목</h2>
<p>설명입니다.</p>
h2 바로 다음에 있는 p를 선택하려면,
h2 + p
처럼 작성할 수 있습니다.
이번 사이트도 같은 원리를 사용할 수 있습니다.
#product_description + p
사람의 말로 풀어보면,
id가 product_description인 요소를 찾고
그 바로 다음에 있는
p 태그를 선택
이라는 뜻입니다.
우리가 원하는 상품 설명이 정확히 여기에 있습니다.
Rust에서 상품 설명 가져오기
이제 기존 parse_book_detail() 함수에 선택자를 하나 추가해보겠습니다.
let description_selector =
Selector::parse(
"#product_description + p"
)
.unwrap();
그리고 지금까지와 같은 방식으로 텍스트를 가져옵니다.
let description = document
.select(&description_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
결과는 다음과 같은 긴 문자열입니다.
It's hard to imagine a world without A Light in the Attic...
코드 자체는 어렵지 않습니다.
다만 이번에는 단순히 클래스 이름을 찾는 것을 넘어 HTML 요소 사이의 관계를 이용해서 데이터를 찾았다는 점이 중요합니다.
CSS 선택자는 단순히,
.class
#id
tag
만 찾는 도구가 아닙니다.
HTML 구조를 알고 있다면 요소 사이의 관계를 이용해서도 원하는 위치를 상당히 정확하게 찾을 수 있습니다.
이번에는 책 표지 이미지를 찾아봅시다
상세 페이지의 이미지 HTML을 살펴보면 다음과 같은 구조를 확인할 수 있습니다.
<div id="product_gallery" class="carousel">
<div class="thumbnail">
<div class="carousel-inner">
<div class="item active">
<img
src="../../media/cache/2c/da/example.jpg"
alt="A Light in the Attic"
>
</div>
</div>
</div>
</div>
우리가 원하는 것은 <img> 태그의 src 속성입니다.
이미지를 찾는 선택자는 여러 가지로 작성할 수 있습니다.
예를 들면,
#product_gallery img
정도면 충분합니다.
Rust 코드로 작성하면,
let image_selector =
Selector::parse(
"#product_gallery img"
)
.unwrap();
가 됩니다.
그리고 src 속성을 가져옵니다.
let image_path = document
.select(&image_selector)
.next()
.unwrap()
.value()
.attr("src")
.unwrap();
출력해보면 대략 이런 결과를 얻게 됩니다.
../../media/cache/2c/da/example.jpg
이미지 주소를 가져오는 데는 성공했습니다.
하지만 바로 사용할 수 있는 주소는 아닙니다.
상대 URL 문제가 다시 돌아왔습니다
3편에서 책의 상세 페이지 주소를 가져올 때도 비슷한 문제가 있었습니다.
그때는,
catalogue/a-light-in-the-attic_1000/index.html
이라는 상대 URL을,
https://books.toscrape.com/
과 합쳐서 절대 URL로 만들었습니다.
그래서 다음 함수를 만들었죠.
fn make_detail_url(path: &str) -> String {
format!("{}{}", TARGET_URL, path)
}
그런데 이번 이미지 주소는 조금 더 까다롭게 생겼습니다.
../../media/cache/2c/da/example.jpg
여기 있는 ../는 한 단계 위의 디렉터리로 이동한다는 뜻입니다.
두 개가 있으니,
../../
두 단계 위로 올라갑니다.
이런 주소를 단순히 문자열로 붙이면 제대로 된 URL을 만들기가 까다로워집니다.
예를 들어,
format!("{}{}", TARGET_URL, image_path)
를 사용하면,
https://books.toscrape.com/../../media/cache/...
라는 보기에도 수상한 주소가 만들어집니다.
이제 3편에서 잠시 미뤄두었던 URL 전용 도구를 알아볼 시간이 왔습니다.
url 라이브러리를 추가해봅시다
Rust에서는 URL을 다루기 위한 url crate를 사용할 수 있습니다.
터미널에서 다음 명령을 실행합니다.
cargo add url
그리고 코드 위쪽에 다음을 추가합니다.
use url::Url;
Url 객체를 이용하면 문자열을 억지로 잘라 붙이지 않고 URL의 규칙에 맞게 상대 주소를 처리할 수 있습니다.
Url::join()으로 상대 URL 합치기
먼저 현재 상세 페이지의 주소가 있다고 해보겠습니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
그리고 HTML에서 가져온 이미지 주소는,
../../media/cache/2c/da/example.jpg
입니다.
Rust에서는 다음처럼 처리할 수 있습니다.
let base_url = Url::parse(detail_url)
.unwrap();
let image_url = base_url
.join(image_path)
.unwrap();
Url::parse()는 문자열로 된 주소를 URL 구조로 해석합니다.
Url::parse(detail_url)
그리고,
.join(image_path)
를 이용하면 현재 URL을 기준으로 상대 경로를 계산해줍니다.
결과적으로,
../../media/cache/...
가 자동으로 정리되어,
https://books.toscrape.com/media/cache/...
형태의 정상적인 절대 URL이 만들어집니다.
웹브라우저가 상대 URL을 보고 목적지를 계산하는 것과 비슷한 작업을 Url이 대신해주는 셈입니다.
이미지 URL 변환도 함수로 만들어봅시다
앞으로 이미지뿐 아니라 다른 상대 URL을 처리해야 할 수도 있으니 함수로 분리해보겠습니다.
fn make_absolute_url(
base_url: &str,
path: &str,
) -> String {
Url::parse(base_url)
.unwrap()
.join(path)
.unwrap()
.to_string()
}
함수의 역할은 단순합니다.
기준 URL
+
상대 URL
↓
절대 URL
사용할 때는 다음처럼 작성합니다.
let image_url = make_absolute_url(
detail_url,
image_path,
);
결과는 완전한 이미지 주소입니다.
https://books.toscrape.com/media/cache/...
이제 브라우저에서도 직접 열 수 있고, 나중에 Markdown이나 HTML의 이미지 주소로도 사용할 수 있습니다.
그런데 왜 TARGET_URL이 아니라 상세 페이지 주소를 기준으로 할까요?
여기서 한 가지 중요한 부분이 있습니다.
다음처럼 작성할 수도 있을 것 같습니다.
make_absolute_url(
TARGET_URL,
image_path,
);
하지만 상대 URL은 원칙적으로 그 URL이 등장한 현재 문서의 주소를 기준으로 해석해야 합니다.
이미지 주소가,
../../media/cache/...
라고 되어 있는 이유도 상세 페이지의 위치를 기준으로 두 단계 위로 올라가라는 뜻입니다.
현재 상세 페이지는,
https://books.toscrape.com/
catalogue/
a-light-in-the-attic_1000/
index.html
위치에 있습니다.
여기에서,
../../
를 적용하면,
https://books.toscrape.com/
까지 올라가게 됩니다.
그 뒤에,
media/cache/...
가 붙는 것입니다.
그래서 상대 URL을 안전하게 처리할 때는 해당 URL이 실제로 등장한 페이지의 주소를 기준으로 사용하는 것이 좋습니다.
Book 구조체에 데이터를 추가해봅시다
이제 새로운 데이터 두 개를 얻었습니다.
description
image_url
기존 Book 구조체에 추가하겠습니다.
struct Book {
title: String,
price: f64,
stock: u32,
rating: u8,
description: String,
image_url: String,
}
이제 책 하나에는 다음 정보가 들어 있습니다.
Book
├── title
├── price
├── stock
├── rating
├── description
└── image_url
처음보다 제법 책다운 데이터가 되었습니다.
parse_book_detail()에 URL도 전달해봅시다
여기서 기존 함수에 변화가 하나 필요합니다.
지금까지 parse_book_detail()은 HTML만 전달받았습니다.
fn parse_book_detail(
html: &str
) -> Book
하지만 이미지의 상대 URL을 절대 URL로 바꾸려면 현재 상세 페이지 주소도 알아야 합니다.
따라서 매개변수를 하나 추가하겠습니다.
fn parse_book_detail(
html: &str,
detail_url: &str,
) -> Book
이제 함수 안에서 상세 페이지 주소를 이용해 이미지 URL을 만들 수 있습니다.
설명과 이미지까지 함께 파싱해봅시다
기존 함수에 두 개의 선택자를 추가합니다.
let description_selector =
Selector::parse(
"#product_description + p"
)
.unwrap();
let image_selector =
Selector::parse(
"#product_gallery img"
)
.unwrap();
상품 설명을 가져옵니다.
let description = document
.select(&description_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
이미지의 상대 주소도 가져옵니다.
let image_path = document
.select(&image_selector)
.next()
.unwrap()
.value()
.attr("src")
.unwrap();
그리고 절대 URL로 변환합니다.
let image_url =
make_absolute_url(
detail_url,
image_path,
);
마지막으로 Book에 모두 담습니다.
Book {
title,
price,
stock,
rating,
description,
image_url,
}
이제 상세 페이지 하나를 파싱하면 필요한 주요 데이터 대부분이 한꺼번에 반환됩니다.
수정된 main() 함수
parse_book_detail()에 매개변수가 하나 추가되었으니 main()에서도 수정이 필요합니다.
기존에는,
let book =
parse_book_detail(&detail_html);
이라고 작성했습니다.
이제 상세 페이지 주소도 함께 넘겨줍니다.
let book =
parse_book_detail(
&detail_html,
&detail_url,
);
그리고 결과를 출력해보겠습니다.
println!("제목: {}", book.title);
println!("가격: {}", book.price);
println!("재고: {}", book.stock);
println!("별점: {}", book.rating);
println!("설명: {}", book.description);
println!("이미지: {}", book.image_url);
println!();
프로그램을 실행합니다.
cargo run
정상적으로 동작하면 다음과 비슷한 결과를 확인할 수 있습니다.
제목: A Light in the Attic
가격: 51.77
재고: 22
별점: 3
설명: It's hard to imagine a world without A Light in the Attic...
이미지: https://books.toscrape.com/media/cache/...
이제 우리가 처음 목표로 했던 책 정보가 거의 완성되었습니다.
지금까지 만든 Book을 한 번 살펴볼까요?
현재 Book 구조체는 다음과 같습니다.
struct Book {
title: String,
price: f64,
stock: u32,
rating: u8,
description: String,
image_url: String,
}
단순한 구조체처럼 보이지만 여기까지 데이터를 가져오기 위해 꽤 많은 작업을 거쳤습니다.
웹 서버에 요청
↓
목록 HTML 수신
↓
책 링크 추출
↓
상세 URL 생성
↓
상세 페이지 요청
↓
HTML 파싱
↓
제목 추출
가격 추출
재고 추출
별점 추출
설명 추출
이미지 추출
↓
문자열 데이터 정제
↓
Book 생성
우리가 웹브라우저로 보고 있던 한 페이지가 이제 Rust 프로그램 안에서 하나의 구조화된 데이터로 들어온 것입니다.
BookLink의 제목은 이제 필요할까요?
여기서 프로그램을 보다 보면 재미있는 점 하나를 발견할 수 있습니다.
현재 BookLink는 다음과 같습니다.
struct BookLink {
title: String,
url: String,
}
그런데 상세 페이지에서도 다시 제목을 가져옵니다.
struct Book {
title: String,
...
}
즉, 현재 제목은 목록 페이지에서도 한 번 가져오고 상세 페이지에서도 또 가져오고 있습니다.
처음에는 목록에서 어떤 책을 찾았는지 확인하기 위해 제목을 함께 저장했지만, 지금 프로그램의 최종 목적만 생각한다면 상세 페이지 URL만 있어도 충분할 수 있습니다.
예를 들면 다음처럼 구조를 단순화할 수도 있습니다.
struct BookLink {
url: String,
}
혹은 굳이 구조체를 만들지 않고,
Vec<String>
으로 URL 목록만 저장할 수도 있습니다.
하지만 이번 연재에서는 BookLink를 그대로 유지하겠습니다.
목록 페이지에서 얻은 정보와 상세 페이지에서 얻은 정보를 비교하거나, 상세 페이지 요청에 실패했을 때 어떤 책인지 확인하는 데 제목이 유용할 수 있기 때문입니다.
프로그램을 만들 때는 이렇게,
지금 당장 필요하지 않은 데이터라고 해서 반드시 쓸모없는 것은 아니다.
라는 점도 생각해볼 수 있습니다.
이번 편의 완성 코드
지금까지 작성한 코드를 하나로 합쳐보겠습니다.
use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{Html, Selector};
use url::Url;
const TARGET_URL: &str =
"https://books.toscrape.com/";
struct BookLink {
title: String,
url: String,
}
struct Book {
title: String,
price: f64,
stock: u32,
rating: u8,
description: String,
image_url: 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 make_absolute_url(
base_url: &str,
path: &str,
) -> String {
Url::parse(base_url)
.unwrap()
.join(path)
.unwrap()
.to_string()
}
fn parse_price(text: &str) -> f64 {
text
.trim()
.trim_start_matches('£')
.parse::<f64>()
.unwrap()
}
fn parse_stock(text: &str) -> u32 {
let (_, stock_text) =
text.split_once('(').unwrap();
stock_text
.split_whitespace()
.next()
.unwrap()
.parse::<u32>()
.unwrap()
}
fn parse_rating(text: &str) -> u8 {
match text {
"One" => 1,
"Two" => 2,
"Three" => 3,
"Four" => 4,
"Five" => 5,
_ => 0,
}
}
fn parse_book_detail(
html: &str,
detail_url: &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 description_selector =
Selector::parse(
"#product_description + p"
)
.unwrap();
let image_selector =
Selector::parse(
"#product_gallery img"
)
.unwrap();
let title = document
.select(&title_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let price_text = document
.select(&price_selector)
.next()
.unwrap()
.text()
.collect::<String>();
let stock_text = document
.select(&stock_selector)
.next()
.unwrap()
.text()
.collect::<String>();
let rating_text = document
.select(&rating_selector)
.next()
.unwrap()
.value()
.attr("class")
.unwrap()
.split_whitespace()
.nth(1)
.unwrap();
let description = document
.select(&description_selector)
.next()
.unwrap()
.text()
.collect::<String>()
.trim()
.to_string();
let image_path = document
.select(&image_selector)
.next()
.unwrap()
.value()
.attr("src")
.unwrap();
let price =
parse_price(&price_text);
let stock =
parse_stock(stock_text.trim());
let rating =
parse_rating(rating_text);
let image_url =
make_absolute_url(
detail_url,
image_path,
);
Book {
title,
price,
stock,
rating,
description,
image_url,
}
}
#[::]
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,
&detail_url,
);
println!(
"제목: {}",
book.title
);
println!(
"가격: {}",
book.price
);
println!(
"재고: {}",
book.stock
);
println!(
"별점: {}",
book.rating
);
println!(
"설명: {}",
book.description
);
println!(
"이미지: {}",
book.image_url
);
println!();
}
Ok(())
}
이번에는 코드가 제법 길어졌습니다.
하지만 새로운 기능이 갑자기 많아진 것은 아닙니다.
대부분 지난 편까지 만들어놓은 코드에,
description
image_url
두 개의 데이터를 추가했을 뿐입니다.
프로그램을 기능별로 나누어놓으면 이렇게 새로운 기능을 추가할 때 기존 코드를 크게 뒤집지 않아도 됩니다.
이제 수집할 데이터는 거의 다 모았습니다
처음 연재에서 잡았던 프로그램 구조를 다시 확인해보겠습니다.
도서 목록 페이지 요청
↓
각 도서의 상세 페이지 주소 수집
↓
상세 페이지 요청
↓
제목·가격·평점·재고·설명 추출
↓
Markdown 파일 생성
현재 상태는 다음과 같습니다.
[완료] 도서 목록 페이지 요청
↓
[완료] 상세 페이지 주소 수집
↓
[완료] 상세 페이지 요청
↓
[완료] 제목·가격·평점·재고·설명·이미지 추출
↓
[다음] Markdown 파일 생성
이제 드디어 수집이라는 산의 정상에 거의 도착했습니다.
다음부터는 방향이 조금 달라집니다.
웹에서 정보를 가져오는 것보다 가져온 데이터를 파일로 만들어 남기는 과정으로 넘어갑니다.
7편 예고: 책 한 권을 Markdown 파일로 만들어봅시다
현재 책 한 권은 Rust 안에서 다음과 같은 데이터로 존재합니다.
Book {
title
price
stock
rating
description
image_url
}
하지만 프로그램이 종료되면 이 데이터도 사라집니다.
다음 편에서는 이 Book 데이터를 실제 파일로 저장해보겠습니다.
최종 결과는 대략 다음과 같은 형태가 될 것입니다.
---
title: "A Light in the Attic"
price: 51.77
stock: 22
rating: 3
image: "https://books.toscrape.com/media/cache/..."
---
It's hard to imagine a world without A Light in the Attic...
그리고 Rust의,
std::fs
또는 비동기 파일 작업을 위한 Tokio의 파일 API를 이용해,
books/
└── a-light-in-the-attic/
└── index.md
와 같은 실제 파일을 생성해보겠습니다.
여기까지 오면 처음 연재를 시작하면서 그렸던,
웹페이지
↓
데이터 수집
↓
Markdown
이라는 흐름이 처음으로 끝까지 연결됩니다.
6편까지가 스크래퍼의 입력부였다면, 다음 편부터는 결과물을 만드는 출력부로 넘어가는 셈입니다.
이번 편에서 배운 내용
이번 편에서는 다음 내용을 구현했습니다.
- 상품 설명 HTML 구조 확인
- CSS의 인접 형제 선택자
+사용 #product_description + p로 설명 추출- 이미지의
src속성 추출 ../가 포함된 상대 URL 이해urlcrate 추가Url::parse()사용Url::join()을 이용한 상대 URL 처리- 상대 URL의 기준이 되는 페이지 이해
make_absolute_url()함수 작성Book구조체에description과image_url추가parse_book_detail()에 상세 페이지 URL 전달- 상세 페이지 주요 데이터 수집 완성
다음 편에서는 지금까지 수집한 데이터를 실제 Markdown 파일로 저장해보겠습니다.