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

들어가며

지난 7편에서는 지금까지 수집한 Book 데이터를 Markdown 문자열로 변환하고, 실제 .md 파일로 저장하는 것까지 진행했습니다.

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

지난 편까지 프로그램을 실행하면 다음과 같은 파일들이 생성되었습니다.

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

일단 파일로 저장한다는 목적은 달성했습니다.

하지만 이 파일들을 실제 콘텐츠 프로젝트에 사용한다고 생각하면 조금 아쉽습니다.

book-17.md라는 파일명만 보고 어떤 책인지 바로 알아보기 어렵고, 책이 1,000권까지 늘어난다면 관리하기도 꽤 불편해집니다.

그래서 이번에는 결과물을 다음처럼 정리해보겠습니다.

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

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

└── soumission/
    └── index.md

책 제목을 사람이 알아볼 수 있는 디렉터리 이름으로 만들고, 각 디렉터리 안에 index.md를 저장하는 방식입니다.

정적 사이트 생성기를 사용해보셨다면 꽤 익숙한 구조일 겁니다.

이를 위해 이번 편에서는 slug 생성과 디렉터리 관리를 다뤄보겠습니다.


먼저 slug가 왜 필요한지 다시 생각해봅시다

지난 편에서 잠깐 slug에 대해 이야기했습니다.

다음과 같은 책 제목이 있다고 해보겠습니다.

A Light in the Attic

그대로 디렉터리 이름에 사용한다면,

A Light in the Attic/

처럼 됩니다.

물론 많은 운영체제에서는 이런 이름도 사용할 수 있습니다.

하지만 웹페이지의 URL이나 콘텐츠 경로로 사용하기에는 다음과 같은 형태가 훨씬 깔끔합니다.

a-light-in-the-attic/

이렇게 사람이 읽을 수 있는 문자열을 URL이나 파일 경로에 적합한 형태로 바꾼 것을 흔히 slug라고 부릅니다.

다른 예를 살펴보겠습니다.

Rust Web Scraping Example

을 slug로 만들면,

rust-web-scraping-example

정도가 됩니다.

보통 다음과 같은 과정이 들어갑니다.

대문자를 소문자로 변경

공백을 - 로 변경

특수문자 정리

연속된 구분자 정리

직접 함수를 만들어볼 수도 있지만, 이 작업은 생각보다 고려해야 할 경우가 많습니다.

그래서 이번에는 이미 만들어진 라이브러리를 사용해보겠습니다.


slug 라이브러리를 추가해봅시다

터미널에서 다음 명령을 실행합니다.

cargo add slug

그리고 코드 위쪽에서 slugify() 함수를 가져옵니다.

use slug::slugify;

사용 방법은 상당히 간단합니다.

let slug = slugify(
    "A Light in the Attic"
);

결과는 다음과 같습니다.

a-light-in-the-attic

한 줄이면 우리가 원하는 형태가 만들어집니다.


실제 책 제목을 slug로 바꿔봅시다

현재 Book에는 제목이 들어 있습니다.

book.title

따라서 다음과 같이 사용할 수 있습니다.

let slug =
    slugify(&book.title);

예를 들어,

A Light in the Attic

이라는 제목이라면,

a-light-in-the-attic

이 만들어집니다.

이 값을 디렉터리 이름으로 사용하면 됩니다.

books/
└── a-light-in-the-attic/

숫자로 된 book-1보다 훨씬 알아보기 쉽습니다.


결과물을 담을 books 디렉터리를 만들어봅시다

지금까지는 Markdown 파일을 프로젝트 루트에 바로 만들었습니다.

rust-book-scraper/
├── book-1.md
├── book-2.md
├── book-3.md
└── ...

이번에는 모든 결과물을 books라는 디렉터리 안에 넣겠습니다.

Rust 표준 라이브러리에는 디렉터리를 생성하기 위한 함수가 있습니다.

fs::create_dir_all("books")
    .unwrap();

create_dir_all()은 필요한 디렉터리를 생성합니다.

이미 해당 디렉터리가 존재하더라도 정상적으로 처리할 수 있기 때문에 여러 단계의 디렉터리를 만들 때 편리하게 사용할 수 있습니다.

예를 들어,

fs::create_dir_all(
    "books/a-light-in-the-attic"
)
.unwrap();

를 실행하면 필요한 디렉터리가 한 번에 생성됩니다.

books/
└── a-light-in-the-attic/

경로를 문자열로 직접 만들 수도 있습니다

우리가 원하는 경로는 다음과 같습니다.

books/a-light-in-the-attic

따라서 format!()을 사용할 수도 있습니다.

let dir_path =
    format!(
        "books/{}",
        slug
    );

그리고,

fs::create_dir_all(
    &dir_path
)
.unwrap();

을 호출하면 됩니다.

이 방식도 정상적으로 동작합니다.

하지만 파일 경로를 다룰 때는 문자열만 사용하는 것보다 조금 더 적합한 도구가 있습니다.


파일 경로를 위한 PathBuf

Rust 표준 라이브러리에는 파일과 디렉터리 경로를 다루기 위한 PathPathBuf가 있습니다.

이번에는 경로를 직접 만들어야 하므로 PathBuf를 사용해보겠습니다.

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

use std::path::PathBuf;

그리고 기본 출력 디렉터리를 만듭니다.

let mut dir_path =
    PathBuf::from("books");

현재 상태는,

books

입니다.

여기에 slug를 추가합니다.

dir_path.push(&slug);

그러면 dir_path 변수가 갖는 경로는,

books/a-light-in-the-attic

이 됩니다.

이를 위해 dir_path 변수를 mut 키워드로 수정 가능하게 정의했다는 걸 캐치하셨다면 이제 Rust 코드가 꽤 숙달된 분이라고 할 수 있겠습니다.

운영체제마다 파일 경로를 표현하는 방식에 차이가 있을 수 있기 때문에, 파일 경로는 이런 전용 자료형을 이용해 다루는 습관을 들이는 것도 좋습니다.


디렉터리를 실제로 만들어봅시다

이제 만든 경로를 create_dir_all()에 전달합니다.

fs::create_dir_all(
    &dir_path
)
.unwrap();

결과는 다음과 같습니다.

books/
└── a-light-in-the-attic/

아직 안에는 아무것도 없습니다.

다음 단계에서는 지난 편에서 만든 Markdown 파일을 여기에 저장하면 됩니다.


index.md 경로를 만들어봅시다

정적 사이트 생성기에서 흔히 사용하는 형태로 각 디렉터리 안에 index.md를 저장해보겠습니다.

현재 dir_path는,

books/a-light-in-the-attic

입니다.

여기에,

index.md

를 추가하면 됩니다.

그런데 기존 dir_path 자체를 변경하기보다는 하나를 복사해서 사용해보겠습니다.

let mut file_path =
    dir_path.clone();

file_path.push("index.md");

이제 file_path에는,

books/a-light-in-the-attic/index.md

가 들어 있습니다.

우리가 처음 목표로 했던 구조가 완성됐습니다.

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

파일 저장 함수도 경로를 받도록 바꿔봅시다

지난 편에서는 다음 함수를 만들었습니다.

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

그런데 이제 단순한 파일명 대신 PathBuf로 만든 경로를 전달하고 싶습니다.

Rust의 fs::write()는 경로 형태의 값을 받을 수 있기 때문에 함수도 조금 수정하겠습니다.

use std::path::Path;

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

이제 다음처럼 사용할 수 있습니다.

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

파일은 정확히,

books/a-light-in-the-attic/index.md

에 저장됩니다.


저장 과정 전체를 함수로 만들어볼까요?

현재 main()에 넣어야 할 코드를 정리해보겠습니다.

let slug =
    slugify(&book.title);

let mut dir_path =
    PathBuf::from("books");

dir_path.push(&slug);

fs::create_dir_all(
    &dir_path
)
.unwrap();

let mut file_path =
    dir_path.clone();

file_path.push("index.md");

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

동작은 잘하지만 main()에 또 여러 역할이 들어오기 시작했습니다.

우리는 지금까지 계속 이런 코드를 별도의 함수로 분리해왔습니다.

이번에도 마찬가지로 해보겠습니다.

fn save_book(
    book: &Book,
    markdown: &str,
) -> std::io::Result<PathBuf> {
    let slug =
        slugify(&book.title);

    let mut dir_path =
        PathBuf::from("books");

    dir_path.push(slug);

    fs::create_dir_all(
        &dir_path
    )?;

    let mut file_path =
        dir_path;

    file_path.push("index.md");

    fs::write(
        &file_path,
        markdown,
    )?;

    Ok(file_path)
}

조금 새로운 형태가 등장했습니다.

하나씩 살펴보겠습니다.


이번에는 ?를 파일 작업에서도 사용합니다

지금까지 파일 작업에서는 다음처럼 unwrap()을 사용했습니다.

fs::create_dir_all(
    &dir_path
)
.unwrap();

하지만 방금 만든 함수에서는,

fs::create_dir_all(
    &dir_path
)?;

처럼 작성했습니다.

지난 HTTP 요청 코드에서 이미 익숙한 문법입니다.

.send()
.await?

파일 생성 역시 실패할 수 있기 때문에 Result를 반환합니다.

현재 save_book() 함수 자체도,

std::io::Result<PathBuf>

를 반환하기 때문에 중간에 오류가 발생하면 ?를 통해 호출한 쪽으로 오류를 전달할 수 있습니다.

파일 작성도 마찬가지입니다.

fs::write(
    &file_path,
    markdown,
)?;

모든 작업이 성공하면 마지막에 생성한 파일 경로를 반환합니다.

Ok(file_path)

이제 파일을 저장하는 과정에서 unwrap()도 하나 줄었습니다.


왜 파일 경로까지 반환할까요?

굳이 다음처럼,

Ok(())

만 반환해도 파일 저장 자체에는 문제가 없습니다.

그런데 생성한 경로를 반환하면 main()에서 어떤 파일이 만들어졌는지 바로 출력할 수 있습니다.

let file_path =
    save_book(
        &book,
        &markdown,
    )
    .unwrap();

println!(
    "{} 저장 완료",
    file_path.display()
);

실행 결과는 다음과 같은 형태입니다.

books/a-light-in-the-attic/index.md 저장 완료

어떤 결과물이 만들어졌는지 바로 확인할 수 있어 편리합니다.

PathBuf 자체를 그대로 출력하기보다,

file_path.display()

를 사용하는 것도 눈여겨볼 만합니다.

파일 경로를 사람이 읽을 수 있는 형태로 출력할 때 사용할 수 있습니다.


그런데 제목이 같은 책이 두 권이라면?

여기까지는 아주 잘 동작합니다.

그런데 한 가지 문제가 숨어 있습니다.

다음처럼 제목이 완전히 같은 책이 두 권 있다고 가정해보겠습니다.

Rust Programming
Rust Programming

두 책 모두 slug는 같습니다.

rust-programming

그러면 둘 다 같은 경로를 사용하게 됩니다.

books/rust-programming/index.md

첫 번째 책을 저장한 뒤 두 번째 책을 저장하면 기존 파일이 덮어써집니다.

지난 편의 book.md 문제가 다른 모습으로 다시 나타난 셈입니다.


같은 디렉터리가 있다면 번호를 붙여봅시다

중복을 처리하는 방법은 여러 가지가 있습니다.

ISBN이나 UPC 같은 고유한 값을 slug 뒤에 붙일 수도 있고, 목록의 순번을 이용할 수도 있습니다.

이번에는 비교적 단순한 방법으로,

rust-programming
rust-programming-2
rust-programming-3

처럼 처리해보겠습니다.

이를 위한 함수를 만들어보겠습니다.

fn unique_dir_path(
    base_dir: &str,
    slug: &str,
) -> PathBuf {
    let mut path =
        PathBuf::from(base_dir);

    path.push(slug);

    if !path.exists() {
        return path;
    }

    let mut number = 2;

    loop {
        let mut candidate =
            PathBuf::from(base_dir);

        candidate.push(
            format!(
                "{}-{}",
                slug,
                number
            )
        );

        if !candidate.exists() {
            return candidate;
        }

        number += 1;
    }
}

조금 길어졌지만 원리는 단순합니다.

먼저 기본 경로를 확인합니다.

books/rust-programming

없다면 그대로 사용합니다.

이미 있다면,

books/rust-programming-2

를 확인합니다.

그것도 있다면,

books/rust-programming-3

을 확인합니다.

비어 있는 이름을 찾을 때까지 반복합니다.


exists()로 파일이나 디렉터리 존재 여부 확인하기

방금 코드에서 새로운 메서드가 하나 등장했습니다.

path.exists()

말 그대로 해당 경로가 실제로 존재하는지 확인합니다.

존재한다면,

true

존재하지 않는다면,

false

를 반환합니다.

그래서,

if !path.exists() {
    return path;
}

는,

이 경로가 아직 존재하지 않는다면 그대로 사용하자.

라는 의미입니다.


loop는 언제 끝날까요?

중복 경로를 찾는 부분에서는 loop를 사용했습니다.

loop {
    // ...
}

Rust의 loop는 조건 없이 계속 반복합니다.

그런데 이번 함수는 사용할 수 있는 경로를 찾으면 바로,

return candidate;

를 실행합니다.

따라서 실제 동작은 다음과 같습니다.

rust-programming 존재함

rust-programming-2 존재함

rust-programming-3 없음

return

빈 이름을 찾는 순간 함수 자체가 끝나기 때문에 무한히 반복되지는 않습니다.


save_book()에 중복 처리를 적용해봅시다

이제 기존 save_book() 함수를 수정하겠습니다.

fn save_book(
    book: &Book,
    markdown: &str,
) -> std::io::Result<PathBuf> {
    let slug =
        slugify(&book.title);

    let dir_path =
        unique_dir_path(
            "books",
            &slug,
        );

    fs::create_dir_all(
        &dir_path
    )?;

    let mut file_path =
        dir_path;

    file_path.push("index.md");

    fs::write(
        &file_path,
        markdown,
    )?;

    Ok(file_path)
}

이제 같은 제목이 여러 번 등장해도 기존 파일을 덮어쓰지 않습니다.

결과는 다음과 같은 형태가 됩니다.

books/
├── rust-programming/
│   └── index.md

├── rust-programming-2/
│   └── index.md

└── rust-programming-3/
    └── index.md

조금 더 실제 콘텐츠 프로젝트다운 모습이 됐습니다.


main()은 오히려 더 간단해졌습니다

저장과 관련된 작업을 함수로 분리했기 때문에 main()에서는 더 이상 경로를 직접 다룰 필요가 없습니다.

지난 편에서는 순번을 만들기 위해,

enumerate()

까지 사용했습니다.

하지만 이제 제목에서 slug를 만들기 때문에 순번도 필요하지 않습니다.

따라서 다시 단순한 반복문으로 돌아갈 수 있습니다.

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,
        );

    let markdown =
        render_markdown(&book);

    let file_path =
        save_book(
            &book,
            &markdown,
        )
        .unwrap();

    println!(
        "{} 저장 완료",
        file_path.display()
    );
}

이제 main()을 읽으면 프로그램의 전체 동작이 거의 문장처럼 보입니다.

상세 페이지 주소 생성

HTML 요청

Book 생성

Markdown 생성

Book 저장

세부적인 파일 처리 과정은 각각의 함수 뒤로 숨었습니다.


생성된 결과물을 확인해봅시다

프로그램을 실행합니다.

cargo run

터미널에서는 다음과 비슷한 결과를 확인할 수 있습니다.

books/a-light-in-the-attic/index.md 저장 완료
books/tipping-the-velvet/index.md 저장 완료
books/soumission/index.md 저장 완료
...

프로젝트 디렉터리도 다음과 같이 바뀝니다.

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

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

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

    └── soumission/
        └── index.md

7편의,

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

와 비교하면 훨씬 사용하기 좋은 구조가 되었습니다.

이제 파일만 보더라도 어떤 책인지 알 수 있고, 각 책의 리소스를 같은 디렉터리 안에서 관리하기도 쉬워졌습니다.


이번 편의 완성 코드

지금까지 작성한 기능을 모두 합쳐보겠습니다.

use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{Html, Selector};
use slug::slugify;
use std::fs;
use std::path::PathBuf;
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 unique_dir_path(
    base_dir: &str,
    slug: &str,
) -> PathBuf {
    let mut path =
        PathBuf::from(base_dir);

    path.push(slug);

    if !path.exists() {
        return path;
    }

    let mut number = 2;

    loop {
        let mut candidate =
            PathBuf::from(base_dir);

        candidate.push(
            format!(
                "{}-{}",
                slug,
                number
            )
        );

        if !candidate.exists() {
            return candidate;
        }

        number += 1;
    }
}

fn save_book(
    book: &Book,
    markdown: &str,
) -> std::io::Result<PathBuf> {
    let slug =
        slugify(&book.title);

    let dir_path =
        unique_dir_path(
            "books",
            &slug,
        );

    fs::create_dir_all(
        &dir_path
    )?;

    let mut file_path =
        dir_path;

    file_path.push("index.md");

    fs::write(
        &file_path,
        markdown,
    )?;

    Ok(file_path)
}

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

        let markdown =
            render_markdown(&book);

        let file_path =
            save_book(
                &book,
                &markdown,
            )
            .unwrap();

        println!(
            "{} 저장 완료",
            file_path.display()
        );
    }

    Ok(())
}

이번 편에서는 코드의 기능 자체보다 결과물이 정리되는 방식이 크게 바뀌었습니다.

기존에는,

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

였다면 이제는,

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

가 되었습니다.

이제 만들어진 결과물을 다른 프로젝트로 복사하거나 정적 사이트 생성기의 콘텐츠 디렉터리로 바로 연결하기도 훨씬 쉬워졌습니다.


그런데 아직 20권밖에 가져오지 않았습니다

여기까지 만들고 나면 한 가지 사실을 잊고 있었다는 것을 발견하게 됩니다.

Books to Scrape에는 총 1,000권의 책이 있습니다.

그런데 우리가 현재 가져오는 것은 첫 페이지의 20권뿐입니다.

왜일까요?

현재 프로그램은 처음부터 다음 주소 하나만 요청하고 있기 때문입니다.

https://books.toscrape.com/

사이트 아래쪽을 살펴보면 다음 페이지로 이동하는 버튼이 있습니다.

그리고 두 번째 페이지 주소는 다음과 같은 형태입니다.

https://books.toscrape.com/catalogue/page-2.html

세 번째 페이지는,

https://books.toscrape.com/catalogue/page-3.html

입니다.

즉 지금까지 우리가 만든 스크래퍼는 꽤 그럴듯해졌지만 아직 첫 번째 목록 페이지만 바라보고 있는 스크래퍼입니다.

다음 편부터 이 한계를 없애보겠습니다.


9편 예고: 50페이지를 자동으로 돌아다녀봅시다

다음 편에서는 Books to Scrape의 페이지네이션 구조를 읽어 다음 페이지 주소를 자동으로 찾아가도록 만들어보겠습니다.

단순히,

for page in 1..=50

처럼 페이지 수를 코드에 박아놓을 수도 있습니다.

하지만 그렇게 만들면 사이트의 페이지 수가 바뀌었을 때 프로그램도 수정해야 합니다.

조금 더 스크래퍼답게,

현재 페이지 요청

책 목록 추출

Next 버튼 확인

다음 페이지가 있으면 이동

다시 책 목록 추출

Next가 없으면 종료

하는 방식으로 구현해보겠습니다.

이 과정까지 끝나면 지금까지 20권만 처리하던 프로그램이 사이트의 전체 목록을 스스로 따라다닐 수 있게 됩니다.

1편에서 페이지 하나를 요청하던 작은 프로그램이 드디어 사이트 전체를 순회하기 시작하는 셈입니다:)


이번 편에서 배운 내용

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

  • slug의 개념
  • slug crate 추가
  • slugify()를 이용한 slug 생성
  • Rust에서 디렉터리 자동 생성
  • fs::create_dir_all() 사용
  • PathPathBuf를 이용한 파일 경로 관리
  • 각 책별 디렉터리 생성
  • index.md 파일 저장
  • Path::exists()를 이용한 중복 확인
  • 같은 slug가 존재할 때 번호 추가
  • loop를 이용한 사용 가능한 경로 탐색
  • 파일 작업에서 ?를 이용한 오류 전달
  • 생성한 PathBuf 반환
  • display()를 이용한 경로 출력
  • 순번 기반 파일명 제거
  • 실제 콘텐츠 프로젝트에 가까운 출력 구조 완성

다음 편에서는 첫 페이지에 머물러 있는 프로그램을 확장해 전체 목록 페이지를 자동으로 순회해보겠습니다.

다음 글: Rust 웹 스크래퍼로 여러 페이지 자동 순회하기