실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 6편

들어가며

지난 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,
    }
}

#[tokio::main]
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.url
            );

        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 이해
  • url crate 추가
  • Url::parse() 사용
  • Url::join()을 이용한 상대 URL 처리
  • 상대 URL의 기준이 되는 페이지 이해
  • make_absolute_url() 함수 작성
  • Book 구조체에 descriptionimage_url 추가
  • parse_book_detail()에 상세 페이지 URL 전달
  • 상세 페이지 주요 데이터 수집 완성

다음 편에서는 지금까지 수집한 데이터를 실제 Markdown 파일로 저장해보겠습니다.

다음 글: Rust로 Markdown 파일 자동 생성하기