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

들어가며

지난 6편에서는 상세 페이지에서 상품 설명과 이미지 URL까지 가져오면서 책 한 권에 필요한 핵심 데이터를 거의 모두 수집했습니다.

아직 안 보셨다면 먼저 6편을 보고 오시는 것을 추천드립니다.

현재 우리가 만든 Book 구조체는 다음과 같습니다.

struct Book {
    title: String,
    price: f64,
    stock: u32,
    rating: u8,
    description: String,
    image_url: String,
}

여기까지 오면서 웹페이지에 있던 정보가 Rust 안에서는 하나의 구조화된 데이터가 되었습니다.

하지만 아직 한 가지 중요한 문제가 남아 있습니다.

프로그램을 종료하면 지금까지 수집한 데이터도 함께 사라진다는 점입니다.

터미널에 출력하는 것만으로는 실제 결과물을 남길 수 없습니다.

그래서 이번 편부터는 우리가 수집한 데이터를 파일로 저장하는 단계로 넘어가겠습니다.

최종적으로는 책 한 권마다 다음과 같은 Markdown 파일을 생성하는 것이 목표입니다.

---
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...

처음 연재에서 이야기했던,

웹페이지

데이터 수집

Markdown 파일 생성

이라는 전체 흐름이 드디어 하나로 연결되는 순간입니다.


왜 Markdown으로 저장할까요?

스크래핑한 데이터를 저장하는 방법은 여러 가지가 있습니다.

예를 들면,

JSON
CSV
데이터베이스
텍스트 파일
Markdown

등이 있습니다.

어떤 방식이 가장 좋다고 정해져 있는 것은 아닙니다.

프로그램의 목적에 따라 선택하면 됩니다.

이번 연재에서는 처음부터 Markdown을 선택했습니다.

이유는 단순합니다.

Markdown은 사람이 직접 읽기에도 편하고, 프로그램에서 생성하기도 쉽기 때문입니다.

또한 Hugo, Zola, Jekyll 같은 정적 사이트 생성기에서도 널리 사용할 수 있습니다.

예를 들어 다음처럼 저장된 Markdown 파일은,

---
title: "A Light in the Attic"
price: 51.77
rating: 3
---

책 설명입니다.

SSG에서 하나의 콘텐츠 페이지로 바로 사용할 수도 있습니다.

물론 이번 연재에서는 생성된 데이터를 실제 사이트에 그대로 게시하기보다는, Rust에서 데이터를 파일로 만드는 과정 자체를 배우는 것에 초점을 맞추겠습니다.


Markdown 파일의 구조부터 살펴봅시다

우리가 만들 파일은 크게 두 부분으로 나눌 수 있습니다.

Front Matter

본문

예를 들어,

---
title: "A Light in the Attic"
price: 51.77
stock: 22
rating: 3
image: "https://example.com/image.jpg"
---

책 설명입니다.

위쪽의,

---
title: "A Light in the Attic"
price: 51.77
stock: 22
rating: 3
image: "https://example.com/image.jpg"
---

부분을 흔히 Front Matter라고 합니다.

문서에 대한 메타데이터를 저장하는 영역입니다.

그리고 아래의,

책 설명입니다.

부분이 실제 본문입니다.

우리가 만든 Book 구조체와 비교해보면 거의 그대로 대응됩니다.

Book.title

title

Book.price

price

Book.stock

stock

Book.rating

rating

Book.image_url

image

Book.description

본문

따라서 Book 데이터를 Markdown 문자열로 바꾸는 함수만 만들면 됩니다.


먼저 Markdown 문자열을 만들어봅시다

Rust에서는 format!() 매크로를 이용해 여러 값을 하나의 문자열로 만들 수 있습니다.

3편에서 URL을 만들 때 이미 사용했던 방법입니다.

format!("{}{}", TARGET_URL, path)

이번에는 조금 더 긴 문자열을 만들겠습니다.

fn render_markdown(book: &Book) -> String {
    format!(
r#"---
title: "{}"
price: {}
stock: {}
rating: {}
image: "{}"
---

{}
"#,
        book.title,
        book.price,
        book.stock,
        book.rating,
        book.image_url,
        book.description,
    )
}

코드가 조금 낯설어 보일 수 있습니다.

특히,

r#"..."#

부분이 눈에 들어옵니다.

하나씩 살펴보겠습니다.


r#"..."#는 무엇일까요?

Rust에서는 일반적으로 문자열을 다음처럼 작성합니다.

let text = "Hello Rust";

하지만 문자열 안에 따옴표가 많아지면 이야기가 조금 복잡해집니다.

예를 들어 다음 문자열을 만들고 싶다고 해보겠습니다.

title: "A Light in the Attic"

일반 문자열에서는 내부 따옴표를 이스케이프해야 합니다.

let text =
    "title: \"A Light in the Attic\"";

짧은 문자열이라면 큰 문제는 없습니다.

하지만 Markdown이나 HTML처럼 긴 문서를 코드 안에 넣다 보면,

\"
\n
\\

같은 문자들이 계속 등장하게 됩니다.

코드가 금방 복잡해지겠죠.

이럴 때 Rust에서는 Raw String Literal을 사용할 수 있습니다.

r#"title: "A Light in the Attic""#

r#""# 사이에서는 일반적인 따옴표를 그대로 사용할 수 있습니다.

그래서 여러 줄로 된 Markdown 템플릿을 작성할 때 상당히 편리합니다.


format!() 안에서 값을 넣어봅시다

다시 코드를 보겠습니다.

format!(
r#"---
title: "{}"
price: {}
stock: {}
rating: {}
image: "{}"
---

{}
"#,
    book.title,
    book.price,
    book.stock,
    book.rating,
    book.image_url,
    book.description,
)

문자열 안의 {} 자리에 뒤쪽 값들이 순서대로 들어갑니다.

첫 번째 {}에는,

book.title

두 번째에는,

book.price

세 번째에는,

book.stock

가 들어가는 방식입니다.

예를 들어 Book에 다음 값이 들어 있다면,

title       = A Light in the Attic
price       = 51.77
stock       = 22
rating      = 3
image_url   = https://example.com/image.jpg
description = 책 설명

함수의 결과는 다음과 같습니다.

---
title: "A Light in the Attic"
price: 51.77
stock: 22
rating: 3
image: "https://example.com/image.jpg"
---

책 설명

이제 Rust 안에 있던 구조체가 하나의 Markdown 문서로 변환되었습니다.


우선 터미널에 출력해봅시다

바로 파일로 저장하기 전에 문자열이 제대로 만들어졌는지 확인해보겠습니다.

지난 편의 main() 함수에서 책을 만든 뒤,

let book =
    parse_book_detail(
        &detail_html,
        &detail_url,
    );

다음 코드를 추가합니다.

let markdown =
    render_markdown(&book);

println!("{markdown}");

프로그램을 실행합니다.

cargo run

정상적으로 동작하면 터미널에 다음과 비슷한 Markdown 문서가 출력됩니다.

---
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에서 파일을 저장해봅시다

이번에는 Rust 표준 라이브러리의 파일 기능을 사용해보겠습니다.

코드 위쪽에 다음을 추가합니다.

use std::fs;

그리고 아주 간단하게 파일을 만들 수 있습니다.

fs::write(
    "book.md",
    markdown,
)
.unwrap();

fs::write()는 첫 번째 인자로 파일 경로를 받고, 두 번째 인자로 저장할 내용을 받습니다.

fs::write(
    파일 경로,
    저장할 내용
)

따라서,

fs::write(
    "book.md",
    markdown,
)
.unwrap();

를 실행하면 현재 프로젝트 디렉터리에,

book.md

파일이 만들어집니다.


파일이 정말 만들어졌는지 확인해봅시다

프로그램을 실행합니다.

cargo run

프로젝트 디렉터리를 확인하면 다음과 같이 파일 하나가 생긴 것을 볼 수 있습니다.

rust-book-scraper/
├── Cargo.toml
├── src/
│   └── main.rs
└── book.md

파일을 열어보면 방금 터미널에서 출력했던 Markdown 내용이 그대로 들어 있습니다.

---
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...

드디어 프로그램이 웹에서 가져온 정보를 실제 파일로 남기기 시작했습니다.


그런데 모든 책을 book.md에 저장하면 어떻게 될까요?

현재 우리는 첫 페이지에 있는 20권의 책을 반복문으로 처리하고 있습니다.

for book_link in books {
    // ...
}

그 안에서 매번 다음 코드를 실행한다고 해보겠습니다.

fs::write(
    "book.md",
    markdown,
)
.unwrap();

첫 번째 책이 저장됩니다.

book.md

A Light in the Attic

그다음 두 번째 책을 저장합니다.

그런데 파일 이름이 똑같습니다.

book.md

그러면 기존 파일의 내용이 새 내용으로 덮어써집니다.

A Light in the Attic

삭제

Tipping the Velvet

저장

20권을 모두 처리하고 나면 결국 마지막 책 하나만 남게 됩니다.

당연히 우리가 원하는 결과는 아닙니다.

책마다 서로 다른 파일 이름이 필요합니다.


가장 간단하게 제목을 파일명으로 써볼까요?

처음에는 이런 생각을 할 수 있습니다.

let file_name =
    format!("{}.md", book.title);

예를 들어,

A Light in the Attic

이라면,

A Light in the Attic.md

파일이 만들어집니다.

가능은 합니다.

하지만 실제 파일 이름으로 사용하기에는 조금 불편합니다.

제목에는 공백이 들어갈 수도 있고,

A Light in the Attic

특수문자가 들어갈 수도 있습니다.

Hello: Rust?

운영체제에 따라 사용할 수 없는 문자도 있을 수 있습니다.

웹사이트나 SSG에서 파일 경로로 사용하기에도 깔끔하지 않습니다.

보통 이런 경우 제목을 slug 형태로 변환합니다.


slug는 무엇일까요?

블로그를 운영하신다면 이미 익숙한 개념일 수도 있습니다.

예를 들어 제목이,

A Light in the Attic

이라면 slug는,

a-light-in-the-attic

처럼 만들 수 있습니다.

또 다른 예를 들어보겠습니다.

Rust Web Scraping Example

은,

rust-web-scraping-example

과 같은 형태로 만들 수 있습니다.

주로,

소문자로 변환
공백을 - 로 변환
불필요한 특수문자 제거

과정을 거칩니다.

다만 제대로 된 slug 생성은 생각보다 신경 쓸 부분이 많습니다.

영문뿐 아니라 한글이나 다른 언어가 들어올 수도 있고, 같은 제목의 파일이 존재할 수도 있습니다.

그래서 이번 편에서는 일단 Markdown 파일을 만드는 것에 집중하고, 파일명과 디렉터리를 깔끔하게 만드는 작업은 다음 편에서 별도로 처리하겠습니다.


이번에는 임시로 순번을 파일명에 사용해봅시다

현재 책 목록을 반복할 때는 단순히,

for book_link in books {

를 사용하고 있습니다.

Rust에서는 enumerate()를 이용해 반복하면서 순번도 함께 가져올 수 있습니다.

for (index, book_link)
    in books.into_iter().enumerate()
{
    // ...
}

첫 번째 데이터는,

index = 0

두 번째는,

index = 1

이 됩니다.

우리는 파일 이름을 보기 편하게 1부터 시작하도록 만들겠습니다.

let file_name =
    format!("book-{}.md", index + 1);

그러면 결과는 다음과 같습니다.

book-1.md
book-2.md
book-3.md
...
book-20.md

아직 예쁜 파일명은 아니지만 책마다 서로 다른 파일을 만들 수 있습니다.


파일 저장을 함수로 분리해봅시다

지금까지 계속 해왔듯 파일 저장도 별도의 역할로 분리하겠습니다.

fn save_markdown(
    file_name: &str,
    content: &str,
) -> std::io::Result<()> {
    fs::write(
        file_name,
        content,
    )
}

이번에는 함수 반환형이 조금 다릅니다.

std::io::Result<()>

파일을 저장하는 작업도 실패할 수 있기 때문입니다.

예를 들어,

저장할 권한이 없음
디스크 문제
잘못된 경로

등의 이유로 파일을 만들지 못할 수 있습니다.

그래서 fs::write() 역시 Result를 반환합니다.

함수 안에서 굳이 unwrap()을 호출하지 않고 그 결과를 그대로 반환하도록 만들었습니다.


main()에서 저장 함수를 호출해봅시다

이제 main()의 반복문을 조금 수정하겠습니다.

for (index, book_link)
    in books.into_iter().enumerate()
{
    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,
        );

    let markdown =
        render_markdown(&book);

    let file_name =
        format!(
            "book-{}.md",
            index + 1
        );

    save_markdown(
        &file_name,
        &markdown,
    )
    .unwrap();

    println!(
        "{} 저장 완료",
        file_name
    );
}

프로그램의 흐름은 다음과 같습니다.

책 상세 페이지 요청

Book 생성

Markdown 문자열 생성

파일명 생성

파일 저장

프로그램을 실행하면,

cargo run

다음과 같은 메시지가 출력됩니다.

book-1.md 저장 완료
book-2.md 저장 완료
book-3.md 저장 완료
...
book-20.md 저장 완료

프로젝트 디렉터리를 확인하면 실제 파일도 만들어져 있습니다.

rust-book-scraper/
├── Cargo.toml
├── src/
│   └── main.rs
├── book-1.md
├── book-2.md
├── book-3.md
├── ...
└── book-20.md

처음으로 한 페이지에 있는 책 20권이 각각의 Markdown 파일로 저장되었습니다.


현재 프로그램의 전체 흐름을 다시 봅시다

이제 프로그램은 다음 작업을 모두 수행합니다.

Books to Scrape 접속

목록 HTML 다운로드

상세 페이지 주소 20개 추출

각 상세 페이지 요청

책 정보 추출

데이터 가공

Book 구조체 생성

Markdown 생성

파일 저장

연재 1편에서 처음 그렸던 구조와 비교해보겠습니다.

도서 목록 페이지 요청

각 도서의 상세 페이지 주소 수집

상세 페이지 요청

제목·가격·평점·재고·설명 추출

Markdown 파일 생성

이제 처음 목표로 했던 흐름은 사실상 모두 구현했습니다.

그런데 실제 프로그램이라고 부르기에는 몇 군데가 아직 조금 투박합니다.

가장 먼저 눈에 들어오는 것이 바로 파일 구조입니다.

book-1.md
book-2.md
book-3.md
...

파일이 20개 정도라면 괜찮습니다.

하지만 앞으로 1,000권까지 수집한다면 어떨까요?

book-1.md
book-2.md
book-3.md
...
book-999.md
book-1000.md

조금 난감해집니다. 새로운 과제가 생겼습니다.


이번 편의 완성 코드

지금까지 작성한 기능을 포함한 전체 코드는 다음과 같습니다.

use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{Html, Selector};
use std::fs;
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,
    }
}

fn render_markdown(
    book: &Book
) -> String {
    format!(
r#"---
title: "{}"
price: {}
stock: {}
rating: {}
image: "{}"
---

{}
"#,
        book.title,
        book.price,
        book.stock,
        book.rating,
        book.image_url,
        book.description,
    )
}

fn save_markdown(
    file_name: &str,
    content: &str,
) -> std::io::Result<()> {
    fs::write(
        file_name,
        content,
    )
}

#[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()
    );

    for (index, book_link)
        in books.into_iter().enumerate()
    {
        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,
            );

        let markdown =
            render_markdown(&book);

        let file_name =
            format!(
                "book-{}.md",
                index + 1
            );

        save_markdown(
            &file_name,
            &markdown,
        )
        .unwrap();

        println!(
            "{} 저장 완료",
            file_name
        );
    }

    Ok(())
}

코드가 꽤 길어졌습니다.

하지만 이제 프로그램 안에는 역할이 명확한 함수들이 하나씩 자리 잡고 있습니다.

fetch_html()
    웹페이지 요청

parse_book_links()
    목록 파싱

parse_book_detail()
    상세 페이지 파싱

parse_price()
parse_stock()
parse_rating()
    데이터 정제

render_markdown()
    Markdown 생성

save_markdown()
    파일 저장

처음 하나의 긴 함수 안에서 모든 것을 처리했다면 지금쯤 코드를 읽는 것부터 꽤 고통스러웠을 겁니다.

처음부터 하나씩 역할을 나누어온 덕분에 기능이 늘어나도 구조는 오히려 더 명확해지고 있습니다.


8편 예고: 파일과 디렉터리를 제대로 정리해봅시다

이번 편에서는 일단,

book-1.md
book-2.md
book-3.md

처럼 단순한 이름으로 파일을 저장했습니다.

하지만 실제 정적 사이트 생성기에서 사용할 목적이라면 조금 더 정돈된 구조가 좋습니다.

예를 들면,

books/
├── a-light-in-the-attic/
│   └── index.md

├── tipping-the-velvet/
│   └── index.md

└── soumission/
    └── index.md

같은 형태입니다.

다음 편에서는 책 제목을 파일이나 디렉터리에서 사용하기 좋은 slug로 바꾸고, Rust에서 디렉터리를 자동으로 생성해보겠습니다.

그리고 이미 같은 이름의 디렉터리가 존재한다면 어떻게 처리할지도 함께 살펴보겠습니다.

이제 프로그램이 단순히 데이터를 저장하는 수준을 넘어, 사람이 다시 사용하기 좋은 형태로 결과물을 정리하기 시작합니다.


이번 편에서 배운 내용

이번 편에서는 다음 내용을 구현했습니다.

  • Book 데이터를 Markdown 문서로 변환
  • Markdown Front Matter 구성
  • Rust의 Raw String Literal 사용
  • format!()을 이용한 문서 생성
  • std::fs를 이용한 파일 작업
  • fs::write()로 Markdown 파일 저장
  • 같은 파일명 사용 시 덮어쓰기 문제
  • enumerate()를 이용한 순번 생성
  • 책마다 서로 다른 파일 생성
  • save_markdown() 함수 작성
  • 파일 저장 과정의 Result 이해
  • 프로그램의 입력부터 출력까지 전체 흐름 완성

다음 편에서는 생성한 Markdown 파일을 실제 콘텐츠 프로젝트에서 사용하기 좋은 디렉터리 구조로 정리해보겠습니다.

다음 글: Rust로 파일명과 디렉터리 자동 생성하기