실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 9편
목차
- 들어가며
- 가장 단순한 방법부터 생각해봅시다
- Next 버튼의 HTML을 살펴봅시다
- Next 링크를 가져오는 함수를 만들어봅시다
- 책 상세 페이지 URL에도 같은 문제가 생깁니다
- 여러 페이지를 반복하는 방법을 생각해봅시다
- 페이지 하나를 처리해봅시다
- 현재 페이지의 책들을 저장합니다
- 책을 모두 처리한 뒤 Next를 찾습니다
- 수정된 main()을 완성해봅시다
- for page in 1..=50과 무엇이 다를까요?
- 페이지네이션도 결국 링크 추출입니다
- 이번 편의 완성 코드
- 그런데 이제 속도가 문제가 됩니다
- 10편 예고: 여러 상세 페이지를 동시에 요청해봅시다
- 이번 편에서 배운 내용
들어가며
지난 8편에서는 수집한 책 제목을 slug로 변환하고, 각 책마다 별도의 디렉터리를 생성해 Markdown 파일을 저장했습니다.
아직 안 보셨다면 먼저 8편을 보고 오시는 것을 추천드립니다.
현재 프로그램은 다음과 같은 결과물을 만들어냅니다.
books/
├── a-light-in-the-attic/
│ └── index.md
├── tipping-the-velvet/
│ └── index.md
└── ...
파일 저장까지 제법 그럴듯하게 완성됐습니다.
그런데 지금까지 우리가 애써 외면하고 있던 문제가 하나 있습니다.
프로그램이 첫 번째 목록 페이지만 수집하고 있다는 것입니다.
Books to Scrape의 첫 페이지에는 20권의 책이 표시됩니다.
하지만 전체 사이트에는 훨씬 많은 책이 있고, 아래쪽에는 다음 페이지로 이동하기 위한 버튼이 있습니다.
Page 1 of 50
Next
지금 프로그램이 가져오는 책은,
1 ~ 20
뿐입니다.
오늘은 이 Next 버튼을 프로그램이 직접 찾아서 마지막 페이지가 나올 때까지 목록 페이지를 자동으로 이동하도록 만들어보겠습니다.
가장 단순한 방법부터 생각해봅시다
사이트의 페이지 주소를 살펴보면 일정한 규칙이 있습니다.
두 번째 페이지는,
https://books.toscrape.com/catalogue/page-2.html
세 번째 페이지는,
https://books.toscrape.com/catalogue/page-3.html
입니다.
그렇다면 다음처럼 작성할 수도 있습니다.
for page in 2..=50 {
let url = format!(
"https://books.toscrape.com/catalogue/page-{}.html",
page
);
// 요청
}
실제로 잘 동작합니다.
사이트가 항상 50페이지라는 것을 알고 있다면 가장 간단한 방법일 수도 있습니다.
하지만 한 가지 문제가 있습니다.
페이지 수가 나중에,
50
↓
51
로 늘어난다면 프로그램 코드도 수정해야 합니다.
반대로 데이터가 줄어 40페이지가 된다면 존재하지 않는 페이지까지 요청하게 됩니다.
웹사이트의 구조를 프로그램이 이미 읽고 있는데 굳이 사람이 페이지 수까지 알려줄 필요는 없겠죠.
그래서 이번에는 조금 더 스크래퍼다운 방법을 사용해보겠습니다.
현재 페이지 요청
↓
책 목록 추출
↓
Next 링크 확인
↓
Next가 있으면 이동
↓
다시 반복
↓
Next가 없으면 종료
Next 버튼의 HTML을 살펴봅시다
첫 번째 목록 페이지 아래쪽을 개발자 도구로 확인하면 페이지네이션 영역이 있습니다.
대략 다음과 같은 구조입니다.
<ul class="pager">
<li class="current">
Page 1 of 50
</li>
<li class="next">
<a href="catalogue/page-2.html">
next
</a>
</li>
</ul>
우리가 원하는 것은 이 부분입니다.
<li class="next">
<a href="catalogue/page-2.html">
next
</a>
</li>
CSS 선택자로는 간단하게 표현할 수 있습니다.
li.next a
next 클래스를 가진 li 안의 a 태그를 찾는다는 의미입니다.
Next 링크를 가져오는 함수를 만들어봅시다
지금까지 HTML에서 원하는 요소를 가져오는 방법은 여러 번 사용했습니다.
이번에도 똑같습니다.
fn parse_next_page_url(
html: &str,
current_url: &str,
) -> Option<String> {
let document =
Html::parse_document(html);
let selector =
Selector::parse(
"li.next a"
)
.unwrap();
let href = document
.select(&selector)
.next()?
.value()
.attr("href")?;
Some(
make_absolute_url(
current_url,
href,
)
)
}
이번 함수의 반환형은,
Option<String>
입니다.
왜 String을 바로 반환하지 않을까요?
마지막 페이지에는 Next 버튼이 없기 때문입니다.
이번에는 Option이 프로그램의 종료 조건이 됩니다
지난 편들에서 Option을 몇 번 만났습니다.
예를 들어,
.attr("href")
는 해당 속성이 있을 수도 있고 없을 수도 있기 때문에 Option을 반환했습니다.
이번에는 그 특성을 적극적으로 이용합니다.
Next 버튼이 있다면,
Some("다음 페이지 URL")
을 반환합니다.
마지막 페이지처럼 Next 버튼이 없다면,
None
이 됩니다.
즉,
Some
↓
다음 페이지가 있음
↓
계속 진행
None
↓
다음 페이지가 없음
↓
종료
가 됩니다.
웹사이트의 HTML 구조 자체가 프로그램의 반복 종료 조건이 되는 셈입니다.
이번에는 Option에서도 ?를 사용합니다
다음 코드를 다시 보겠습니다.
let href = document
.select(&selector)
.next()?
.value()
.attr("href")?;
? 자체는 이번 연재에서 처음 등장한 문법이 아닙니다.
1편에서 HTTP 요청을 보낼 때부터 계속 사용했습니다.
.send()
.await?
.error_for_status()?;
8편의 파일 작업에서도 사용했었죠.
fs::create_dir_all(
&dir_path
)?;
fs::write(
&file_path,
markdown,
)?;
다만 지금까지 사용했던 ?는 대부분 Result와 함께 사용했습니다.
이번에는 조금 다릅니다.
현재 parse_next_page_url() 함수의 반환형은,
Option<String>
입니다.
그리고 next()와 attr() 역시 값이 없을 수 있기 때문에 Option을 반환합니다.
.next()
.attr("href")
따라서 여기에서도 ?를 사용할 수 있습니다.
예를 들어 마지막 페이지에는 Next 버튼이 없기 때문에,
document
.select(&selector)
.next()
의 결과는,
None
이 됩니다.
여기에 ?를 붙이면 함수는 그 자리에서 None을 반환하고 종료합니다.
.next()?
마찬가지로 href 속성이 없다면,
.attr("href")?
에서 역시 None을 반환합니다.
덕분에 다음처럼 길게 match를 작성하지 않고도,
let element =
match document
.select(&selector)
.next()
{
Some(element) => element,
None => return None,
};
다음과 같이 간결하게 표현할 수 있습니다.
let element = document
.select(&selector)
.next()?;
지금까지 오류를 호출한 쪽으로 전달하는 데 사용했던 ?가 이번에는 값이 없다는 사실을 그대로 전달하는 역할을 하는 셈입니다.
다음 페이지 URL도 상대 URL입니다
첫 페이지의 Next 링크를 보면,
catalogue/page-2.html
형태입니다.
두 번째 페이지 이후에는 HTML 구조에 따라,
page-3.html
처럼 현재 목록 페이지를 기준으로 한 상대 주소가 등장할 수도 있습니다.
따라서 문자열을 직접 합치는 것보다 6편에서 만든,
make_absolute_url()
을 다시 사용하는 편이 안전합니다.
Some(
make_absolute_url(
current_url,
href,
)
)
이렇게 하면 현재 페이지가,
https://books.toscrape.com/catalogue/page-2.html
이고 다음 링크가,
page-3.html
이라도 자동으로,
https://books.toscrape.com/catalogue/page-3.html
로 변환됩니다.
6편에서 이미지 URL 때문에 만들었던 함수가 이번에는 페이지 이동에도 재사용되고 있습니다.
책 상세 페이지 URL에도 같은 문제가 생깁니다
여기서 중요한 문제가 하나 더 있습니다.
지금까지 첫 페이지에서 가져온 책 링크는 다음과 같은 형태였습니다.
catalogue/a-light-in-the-attic_1000/index.html
그래서 우리는,
fn make_detail_url(path: &str) -> String {
format!("{}{}", TARGET_URL, path)
}
처럼 기본 주소에 문자열을 붙였습니다.
하지만 목록 페이지가 바뀌면 책의 href 역시 현재 목록 페이지를 기준으로 한 상대 URL일 수 있습니다.
따라서 9편부터는 책 URL도 현재 페이지 주소를 기준으로 계산하는 편이 훨씬 안전합니다.
기존 parse_book_links()를 수정하겠습니다.
parse_book_links()에 현재 페이지 URL을 전달해봅시다
기존 함수는,
fn parse_book_links(
html: &str
) -> Vec<BookLink>
이었습니다.
이제 목록 페이지의 URL도 전달합니다.
fn parse_book_links(
html: &str,
page_url: &str,
) -> Vec<BookLink>
그리고 href를 가져온 뒤 바로 절대 URL로 변환하겠습니다.
fn parse_book_links(
html: &str,
page_url: &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 href = element
.value()
.attr("href")
.unwrap();
let url =
make_absolute_url(
page_url,
href,
);
books.push(BookLink {
title,
url,
});
}
books
}
이제 BookLink.url에는 상대 URL이 아니라 처음부터 완전한 상세 페이지 주소가 저장됩니다.
예를 들어,
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
처럼 됩니다.
이제 make_detail_url()은 필요 없어졌습니다
기존에는,
let detail_url =
make_detail_url(
&book_link.
);
를 사용했습니다.
하지만 이제 book_link.url 자체가 이미 절대 URL입니다.
따라서 그냥,
let detail_url =
&book_link.url;
을 사용하면 됩니다.
결과적으로 기존의,
fn make_detail_url(path: &str) -> String {
format!("{}{}", TARGET_URL, path)
}
함수도 삭제할 수 있습니다.
기능을 추가했는데 오히려 함수 하나가 줄었습니다.
코드가 발전한다고 항상 길어지는 것만은 아닙니다.
여러 페이지를 반복하는 방법을 생각해봅시다
이제 필요한 부품은 모두 준비되었습니다.
현재 페이지 URL이 있고,
page_url
페이지를 요청할 수 있으며,
fetch_html()
책 목록을 가져올 수 있고,
parse_book_links()
다음 페이지 URL도 찾을 수 있습니다.
parse_next_page_url()
이제 이 작업을 Next가 없을 때까지 반복하면 됩니다.
현재 페이지 자체도 Option으로 관리해봅시다
처음에는 첫 페이지에서 시작합니다.
let mut page_url =
Some(
TARGET_URL.to_string()
);
아직 다음에 처리할 페이지가 있으므로, page_url 변수의 값은
Some(...)
입니다.
그리고 다음과 같은 반복문을 사용할 수 있습니다.
while let Some(current_url) =
page_url
{
// 현재 페이지 처리
}
while let은 패턴에 맞는 값이 존재하는 동안 반복합니다.
지금은,
page_url = Some(...)
인 동안 계속 실행됩니다.
마지막 페이지를 처리한 뒤,
page_url = None
이 되면 반복문이 끝납니다.
while let을 조금 더 이해해봅시다
다음 코드를 보겠습니다.
while let Some(current_url) =
page_url
{
println!("{current_url}");
}
개념적으로는,
page_url에 URL이 있나?
↓
있음
↓
current_url에 꺼냄
↓
반복
page_url이 None인가?
↓
반복 종료
입니다.
이번 페이지네이션 문제와 아주 잘 맞는 구조입니다.
다음 페이지가 있음
↓
Some(URL)
다음 페이지 없음
↓
None
굳이 페이지 번호를 따로 계산하지 않아도 됩니다.
페이지 하나를 처리해봅시다
반복문 안에서는 먼저 현재 목록 페이지를 요청합니다.
let html =
fetch_html(
&client,
¤t_url,
)
.await?;
그리고 책 목록을 추출합니다.
let books =
parse_book_links(
&html,
¤t_url,
);
여기까지는 지금까지 해온 과정과 같습니다.
다만 이번에는 책 20권을 처리한 뒤 끝나는 것이 아니라, 다음 목록 페이지를 찾아야 합니다.
현재 페이지의 책들을 저장합니다
각 책은 기존과 똑같이 처리합니다.
for book_link in books {
let detail_html =
fetch_html(
&client,
&book_link.,
)
.await?;
let book =
parse_book_detail(
&detail_html,
&book_link.,
);
let markdown =
render_markdown(&book);
let file_path =
save_book(
&book,
&markdown,
)
.unwrap();
println!(
"{} 저장 완료",
file_path.display()
);
}
이 부분은 기존 8편의 로직이 거의 그대로 살아 있습니다.
달라진 점은,
book_link.url
이 이미 완전한 URL이라는 것뿐입니다.
책을 모두 처리한 뒤 Next를 찾습니다
현재 페이지의 책을 모두 저장했다면 이제 다음 페이지를 찾습니다.
page_url =
parse_next_page_url(
&html,
¤t_url,
);
Next가 있다면,
Some("다음 URL")
이 들어갑니다.
그러면 while let이 다시 실행됩니다.
Next가 없다면,
None
이 들어갑니다.
그러면 반복문이 종료됩니다.
전체 흐름을 보면 아주 직관적입니다.
page_url = 첫 페이지
↓
페이지 요청
↓
책 수집
↓
책 저장
↓
Next 확인
↓
page_url = 다음 페이지
↓
반복
마지막 페이지에서는,
Next 없음
↓
page_url = None
↓
종료
가 됩니다.
몇 번째 페이지인지 출력해봅시다
50페이지를 처리하다 보면 지금 어디까지 진행됐는지 궁금할 수 있습니다.
간단한 카운터를 하나 만들어보겠습니다.
let mut page_number = 1;
반복문 안에서 출력합니다.
println!(
"{} 페이지 처리 중",
page_number
);
페이지 하나가 끝나면 증가시킵니다.
page_number += 1;
실행하면,
1 페이지 처리 중
2 페이지 처리 중
3 페이지 처리 중
...
처럼 진행 상황을 확인할 수 있습니다.
크롤링 대상이 많아질수록 이런 작은 로그가 꽤 유용합니다.
저장한 책의 수도 세어봅시다
같은 방식으로 저장한 책 수도 확인할 수 있습니다.
let mut total_books = 0;
책 하나를 저장할 때마다,
total_books += 1;
을 실행합니다.
모든 페이지 처리가 끝난 뒤,
println!(
"총 {}권 저장 완료",
total_books
);
를 출력하면 됩니다.
최종적으로 전체 사이트를 정상적으로 순회했다면 상당히 많은 책이 저장될 것입니다.
수정된 main()을 완성해봅시다
지금까지의 내용을 합치면 다음과 같습니다.
#[::]
async fn main()
-> Result<(), reqwest::Error>
{
let client = Client::new();
let mut page_url =
Some(
TARGET_URL.to_string()
);
let mut page_number = 1;
let mut total_books = 0;
while let Some(current_url) =
page_url
{
println!(
"{} 페이지 처리 중",
page_number
);
let html =
fetch_html(
&client,
¤t_url,
)
.await?;
let books =
parse_book_links(
&html,
¤t_url,
);
for book_link in books {
let detail_html =
fetch_html(
&client,
&book_link.,
)
.await?;
let book =
parse_book_detail(
&detail_html,
&book_link.,
);
let markdown =
render_markdown(&book);
let file_path =
save_book(
&book,
&markdown,
)
.unwrap();
println!(
"{} 저장 완료",
file_path.display()
);
total_books += 1;
}
page_url =
parse_next_page_url(
&html,
¤t_url,
);
page_number += 1;
}
println!(
"총 {}권 저장 완료",
total_books
);
Ok(())
}
처음보다 main()이 길어지기는 했지만 프로그램의 전체 흐름은 오히려 더 명확합니다.
목록 페이지 요청
↓
책 링크 수집
↓
책 상세 페이지 처리
↓
Markdown 저장
↓
다음 페이지 확인
↓
반복
for page in 1..=50과 무엇이 다를까요?
결과만 보면 둘 다 50페이지를 처리할 수 있습니다.
첫 번째 방식은,
for page in 1..=50
페이지 수를 우리가 알고 있다는 전제입니다.
반면 이번에 만든 방식은,
Next가 있는가?
만 확인합니다.
따라서 사이트가,
50페이지
에서,
60페이지
로 늘어나더라도 코드 자체를 수정할 필요가 없습니다.
반대로 마지막 페이지가 40페이지가 되더라도 Next가 사라지는 순간 알아서 종료합니다.
웹사이트가 알려주는 이동 경로를 그대로 따라가는 방식인 셈입니다.
페이지네이션도 결국 링크 추출입니다
여기까지 오면 재미있는 점 하나가 보입니다.
우리가 새롭게 배운 기능처럼 보였던 페이지네이션도 사실 지금까지 해온 작업과 크게 다르지 않습니다.
책 상세 페이지를 찾을 때는,
article.product_pod h3 a
를 찾았습니다.
다음 페이지를 찾을 때는,
li.next a
를 찾았습니다.
둘 다 결국,
HTML 파싱
↓
링크 선택
↓
href 추출
↓
URL 변환
입니다.
웹 스크래핑에서 여러 기능을 배우다 보면 완전히 새로운 기술이 계속 등장하는 것처럼 보이지만, 실제로는 몇 가지 기본 동작을 계속 다른 상황에 적용하는 경우가 많습니다.
이번 편의 완성 코드
이번 편까지 반영한 전체 코드는 다음과 같습니다.
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 make_absolute_url(
base_url: &str,
path: &str,
) -> String {
Url::parse(base_url)
.unwrap()
.join(path)
.unwrap()
.to_string()
}
fn parse_book_links(
html: &str,
page_url: &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 href = element
.value()
.attr("href")
.unwrap();
let url =
make_absolute_url(
page_url,
href,
);
books.push(BookLink {
title,
url,
});
}
books
}
fn parse_next_page_url(
html: &str,
current_url: &str,
) -> Option<String> {
let document =
Html::parse_document(html);
let selector =
Selector::parse(
"li.next a"
)
.unwrap();
let href = document
.select(&selector)
.next()?
.value()
.attr("href")?;
Some(
make_absolute_url(
current_url,
href,
)
)
}
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.);
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)
}
#[::]
async fn main()
-> Result<(), reqwest::Error>
{
let client = Client::new();
let mut page_url =
Some(
TARGET_URL.to_string()
);
let mut page_number = 1;
let mut total_books = 0;
while let Some(current_url) =
page_url
{
println!(
"{} 페이지 처리 중",
page_number
);
let html =
fetch_html(
&client,
¤t_url,
)
.await?;
let books =
parse_book_links(
&html,
¤t_url,
);
for book_link in books {
let detail_html =
fetch_html(
&client,
&book_link.,
)
.await?;
let book =
parse_book_detail(
&detail_html,
&book_link.,
);
let markdown =
render_markdown(&book);
let file_path =
save_book(
&book,
&markdown,
)
.unwrap();
println!(
"{} 저장 완료",
file_path.display()
);
total_books += 1;
}
page_url =
parse_next_page_url(
&html,
¤t_url,
);
page_number += 1;
}
println!(
"총 {}권 저장 완료",
total_books
);
Ok(())
}
이번 편에서 가장 큰 변화는 단순히 책의 수가 늘어난 것이 아닙니다.
프로그램이 이제 미리 알려준 URL만 요청하는 것이 아니라 HTML을 읽고 스스로 다음 이동 경로를 결정한다는 것입니다.
웹 스크래퍼다운 자동화가 한 단계 더 들어간 셈입니다.
그런데 이제 속도가 문제가 됩니다
지금 프로그램은 페이지가 50개라고 하더라도 정상적으로 전체 사이트를 순회할 수 있습니다.
하지만 실제로 실행해보면 한 가지가 눈에 들어올 겁니다.
생각보다 시간이 걸립니다.
왜일까요?
3편에서 이미 살펴봤듯 현재 상세 페이지 요청은 전부 순차적으로 실행되고 있기 때문입니다.
책 1 요청
↓
응답 대기
↓
책 2 요청
↓
응답 대기
↓
책 3 요청
20권일 때는 크게 신경 쓰이지 않았지만 책이 수백 권, 수천 권으로 늘어나면 이야기가 달라집니다.
다행히 우리는 처음부터 Tokio를 사용하고 있습니다.
드디어 비동기 프로그램의 장점을 조금 더 적극적으로 사용할 차례입니다.
10편 예고: 여러 상세 페이지를 동시에 요청해봅시다
다음 편에서는 현재의,
요청
↓
기다림
↓
요청
↓
기다림
구조를,
여러 요청 시작
↓
각 응답이 도착하는 대로 처리
하는 구조로 바꿔보겠습니다.
그렇다고 1,000개의 요청을 한꺼번에 던지는 것은 좋은 방법이 아닙니다.
따라서 동시에 실행할 요청의 개수를 제한하면서 처리하는 방법을 사용하겠습니다.
이번 연재 초반부터 사용했던,
async
await
tokio
가 왜 필요한지를 가장 체감하는 편이 될 것 같습니다.
이번 편에서 배운 내용
이번 편에서는 다음 내용을 구현했습니다.
- 페이지네이션의 기본 구조 이해
li.next a를 이용한 Next 링크 선택- 다음 페이지 URL 추출
Option<String>으로 다음 페이지 표현Option에서?사용- 상대 URL을 현재 목록 페이지 기준으로 변환
- 상세 페이지 URL 처리 방식 개선
while let을 이용한 페이지 반복- Next가 없을 때 자동 종료
- 페이지 진행 상태 출력
- 전체 저장 책 수 계산
- 고정된 페이지 수에 의존하지 않는 크롤러 구현
- 사이트 전체 목록 자동 순회
다음 편에서는 여러 상세 페이지 요청을 제한된 범위 안에서 동시에 처리해 웹 스크래퍼의 속도를 개선해보겠습니다.