실전 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 구조체에 description과 image_url 추가
  • parse_book_detail()에 상세 페이지 URL 전달
  • 상세 페이지 주요 데이터 수집 완성

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

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