Парсинг с конкуренцией в Golang

Введение
Всем привет! я. В этой статье я хочу о тем, как выполнять парсинг веб-страниц с параллелизмом с помощью Golang!
Может возникнуть вопрос, а зачем нам конкуренция? Иногда в одном запросе нам нужно запросить различные данные с нескольких страниц. В таком случае возникнет очередь для парсинга каждой страницы. Но с конкуренцией в GO становится возможным парсить несколько страниц одновременно.
Чтобы дать некоторый контекст тому, что мы будем делать , предлагаем ознакомиться со статьей о конкуренции в Go.
Data Flow Design
Как упоминалось ранее во вступительной части, обычный парсинг для нескольких страниц должен ждать парсинга каждой отдельной страницы, прежде чем переходить к другой. Вот схема данных, чтобы описать, как работает обычный парсинг.

Однако при использовании конкуренции в одном запросе Web Scraper может парсить несколько страниц одновременно. Поток данных ниже описывает, что отличает конкуренцию от обычного парсинга.

Парсинг
Мы попытаемся получить исторические курсы обмена для конкретной валюты.
Веб-сайт https://www.x-rates.com/ идеально подходит для данного примера, поскольку на веб-сайте нет API, а также запрос на получение истории данных происходит по определенной дате.
Это означает, что при получении истории за месяц будет отправлено 30 запросов. В этом примере мы попытаемся получить историческую цену между двумя валютами в пределах диапазона дат.
Затем в следующем разделе мы попытаемся сравнить результат скорости без конкуренции и с ней.
Обзор кода
Финальный код будет большим. Пожалуйста, посмотрите этот репозиторий, в котором содержится полный код парсера, потому что в этой статье мы рассмотрим только основные части https://github.com/moemoe89/go-currency-history.
По сути, чтобы получить содержимое веб-страницы, с Golang мы можем просто использовать пакет net/http и отправить запрос с помощью функции NewRequestWithContext.
Чтобы легко читать теги HTML с помощью селекторов, мы можем использовать библиотеку Go под названием goquery.
Реализация кода
Во-первых, создайте повторно используемую функцию, чтобы получить содержимое страницы и преобразовать его в HTML-документ, с помощью пакета goquery. Чтобы использовать эту функцию, просто нужно передать такие параметры, как целевой URL, тип запроса и другие параметры , если необходимо (заголовок, данные формы, файлы cookie и т. д.).
package utils
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
// GetPage call the client page by HTTP request and extract the body to HTML document.
func GetPage(ctx context.Context, method, siteURL string, cookies []*http.Cookie, headers, formDatas map[string]string, timeout int) (*goquery.Document, []*http.Cookie, error) {
// This function can handle both all methods.
// Initiate this body variable as nil for method that doesn't required body.
body := io.Reader(nil)
// If the request contain form-data, add the form-data parameters to the body.
if len(formDatas) > 0 {
form := url.Values{}
for k, v := range formDatas {
form.Add(k, v)
}
body = strings.NewReader(form.Encode())
}
// Create a new HTTP request with context.
req, err := http.NewRequestWithContext(ctx, method, siteURL, body)
if err != nil {
return nil, nil, fmt.Errorf("failed to create http request context: %w", err)
}
// If the request contain headers, add the header parameters.
if len(headers) > 0 {
for k, v := range headers {
req.Header.Add(k, v)
}
}
// If the request contain cookies, add the cookie parameters.
if len(cookies) > 0 {
for _, c := range cookies {
req.AddCookie(c)
}
}
// Use the default timeout if the timeout parameter isn't configured.
reqTimeout := 10 * time.Second
if timeout != 0 {
reqTimeout = time.Duration(timeout) * time.Second
}
// Use default http Client.
httpClient := &http.Client{
Transport: http.DefaultTransport,
CheckRedirect: nil,
Jar: nil,
Timeout: reqTimeout,
}
// Execute the request.
resp, err := httpClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to execute http request: %w", err)
}
// Close the response body
defer func() { _ = resp.Body.Close() }()
// // Parsing response body to HTML document reader.
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse html: %w", err)
}
// Return HTML doc, cookies.
return doc, resp.Cookies(), nil
}
Далее мы попытаемся получить значение валюты. Исходным целевым URL будет https://www.x-rates.com/historical/?from=IDR&amount=1&date=2022-03-19 . Наш сервис будет иметь такие параметры, как from, to, date.
Функция ниже является окончательным кодом для получения валюты на основе данных параметров. Поскольку эта функция пинимает только определенную дату, нам нужна еще одна функция для выполнения итерации по параметрам временного диапазона.
// getCurrencyHistory gets the currency value on a specific date.
func getCurrencyHistory(ctx context.Context, from, to, date string) (*entities.CurrencyHistory, error) {
urlValues := url.Values{
"from": {to}, // Reverse `from` and `to` due to easily parse the currency value.
"amount": {"1"},
"date": {date},
}
siteURL := fmt.Sprintf("https://www.x-rates.com/historical/?%s", urlValues.Encode())
// Scrape the page.
doc, _, err := utils.GetPage(ctx, http.MethodGet, siteURL, nil, nil, nil, 0)
if err != nil {
return nil, err
}
var currencyHistory *entities.CurrencyHistory
// Scrape the currency value.
doc.Find(".ratesTable tbody tr td").EachWithBreak(func(i int, s *goquery.Selection) bool {
// Scrap the attribute href value from `a` tag HTML.
// https://www.x-rates.com/graph/?from=JPY&to=IDR
// Ignore exists value due to also will check in next line.
href, _ := s.Find("a").Attr("href")
// Reverse `from` and `to` due to easily parse the currency value.
if !strings.Contains(href, "to="+from) {
return true
}
// If the target currency match, scrape the text value.
valueString := s.Find("a").Text()
value, err := strconv.ParseFloat(valueString, 64)
if err != nil {
return true
}
currencyHistory = &entities.CurrencyHistory{
Date: date,
Value: value,
}
return false
})
return currencyHistory, nil
}
После этого нам нужно реализовать часть с конкуренцией. Окончательный код будет выглядеть так:
// getCurrencyHistories gets the currencies value on a range date.
func getCurrencyHistories(ctx context.Context, start, end time.Time, from, to string) ([]*entities.CurrencyHistory, error) {
// Get the number of days between start and end date.
days := int(end.Sub(start).Hours()/24) + 1
currencyHistories := make([]*entities.CurrencyHistory, days)
eg, ctx := errgroup.WithContext(ctx)
idx := 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
// Defined new variable to avoid mismatch value when using goroutine.
d := d
i := idx
// Concurrently gets the value on specific date.
eg.Go(func() (err error) {
currencyHistory, err := getCurrencyHistory(ctx, from, to, d.Format("2006-01-02"))
currencyHistories[i] = currencyHistory
return err
})
idx++
}
// Wait all request finished and check the error.
if err := eg.Wait(); err != nil {
return nil, err
}
return currencyHistories, nil
}
В разделе тестов мы сравним производительность при использовании конкуренции и без нее. Код без параллелизма будет выглядеть так:
// getCurrencyHistories gets the currencies value on a range date.
func getCurrencyHistories(ctx context.Context, start, end time.Time, from, to string) ([]*entities.CurrencyHistory, error) {
// Get the number of days between start and end date.
days := int(end.Sub(start).Hours()/24) + 1
currencyHistories := make([]*entities.CurrencyHistory, days)
idx := 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
currencyHistory, err := getCurrencyHistory(ctx, from, to, d.Format("2006-01-02"))
if err != nil {
return nil, err
}
currencyHistories[idx] = currencyHistory
idx++
}
return currencyHistories, nil
}
Это весь нужный код для объяснения концепции парсинга с конкуренцией в Golang. Подробности можно найти в репозитории, упомянутом в предыдущем разделе.
Итоги
Наконец, мы подошли к интересной части — бенчмаркингу!
Сценарий будет тестировать несколько запросов с разным количеством дат (1, 2, 5, 10, 20 и 30 дней ) . Мы будем просто запускать методы для получения истории цен. Количество запросов основано на различном количестве дней между датой начала и датой окончания.
Например, запрос для 10 дней выглядит так:
v1/currency/history?from=IDR&to=JPY&start_date=2022-03-01&end_date=2022-03-10
Я использую эту спецификацию ПК при проведении теста:
– Мак мини (M1, 2020)
– Чип Apple M1
– 16 ГБ оперативки
macmacOS Monterey Version 12.3
Скорость интернета 42,53 (загрузка) 15,34 (загрузка) 29 мс (пинг)
А вот результат бенчмарка с линейным графиком:

Очевидно при отсутсвие конкуренции время работы скрипта будет постоянно увеличиваться в зависимости от количества запросов. Но при конкуренции время отклика тоже увеличивается, но не существенно.
примечание: время ответа также может зависеть от самого сайта, трафика, скорости интернета, региона и т. д.
Вывод:
После работы над Web Scraping в этой статье , я могу сказать, что реализация в Golang очень проста и мощна.