Попробовать
Назад к блогу

Парсинг на Go: Colly, goquery и chromedp через прокси

Парсинг на Go: Colly, goquery и chromedp через прокси

Go хорошо подходит для парсинга: один скомпилированный файл держит тысячи одновременных запросов и расходует мало памяти. Обычный набор состоит из трёх частей: net/http и goquery скачивают и разбирают статичные страницы, Colly обходит сайт с колбэками, лимитами и пагинацией, а chromedp управляет настоящим Chrome, если данные появляются только после выполнения JavaScript. Все три умеют работать через прокси, а смена IP между запросами занимает несколько строк.

Большинство руководств по парсингу на Go ограничиваются одним примером на Colly. Мы соберём один и тот же небольшой парсер каталога книг тремя способами, подключим прокси с авторизацией на каждом уровне, настроим ротацию выходных IP, аккуратно ограничим параллельность и сохраним результат в CSV и JSON. В конце — таблица типичных ошибок.

Ротируемые прокси для парсеров на Go

Один шлюз для net/http, Colly и chromedp с новым IP на запрос или липкими сессиями

Попробовать

Какую библиотеку выбрать

БиблиотекаРольВыполняет JavaScriptПроксиКогда брать
net/http (стандартная библиотека)HTTP-клиентНетTransport.Proxy, адреса HTTP и SOCKS5Полный контроль, API, минимум зависимостей
goqueryРазбор HTML в стиле jQuery поверх x/net/htmlНетНе нужен, только разборИзвлечение полей по CSS-селекторам
Colly v2Фреймворк обхода: колбэки, лимиты, кеш, асинхронностьНетSetProxy, SetProxyFunc, переключатель по кругуМногостраничный обход, пагинация, переходы по ссылкам
chromedpУправляет Chrome по протоколу DevToolsДаОпция аллокатора ProxyServerСтраницы на скриптах, клики, прокрутка

Практичный порядок такой: обход сайта начинаем с Colly, к net/http с goquery переходим, когда нужен точный контроль над запросами, а chromedp берём только для страниц, в исходном HTML которых данных нет. Если вы только знакомитесь с темой, цикл «скачать — разобрать — сохранить» описан в статье о парсинге сайтов.

Подготовка проекта

Установите актуальную версию Go и создайте модуль:

mkdir shelfscout && cd shelfscout
go mod init example.com/shelfscout
go get github.com/gocolly/colly/v2 github.com/PuerkitoBio/goquery github.com/chromedp/chromedp

Тренируемся на books.toscrape.com — учебном магазине, созданном для отработки парсинга. Настройки прокси берём из переменных окружения: в EXIT_URL полный адрес вида http://user:pass@host:port, в EXIT_HOSTPORT — только host:port для браузера.

Шаг 1. net/http и goquery

Стандартный клиент получает прокси через транспорт. http.ProxyURL принимает разобранный URL, а логин и пароль из него автоматически передаются прокси-серверу.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/PuerkitoBio/goquery"
)

func newFetcher() *http.Client {
	exit, err := url.Parse(os.Getenv("EXIT_URL"))
	if err != nil {
		log.Fatal(err)
	}
	return &http.Client{
		Timeout: 30 * time.Second,
		Transport: &http.Transport{
			Proxy:               http.ProxyURL(exit),
			MaxIdleConnsPerHost: 4,
		},
	}
}

func main() {
	fetcher := newFetcher()
	req, _ := http.NewRequest("GET", "https://books.toscrape.com/", nil)
	req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
	req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.7")

	resp, err := fetcher.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	doc, err := goquery.NewDocumentFromReader(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	doc.Find("article.product_pod").Each(func(_ int, card *goquery.Selection) {
		title, _ := card.Find("h3 a").Attr("title")
		price := card.Find("p.price_color").Text()
		fmt.Printf("%s | %s\n", title, price)
	})
}

В рабочем коде проверяйте resp.StatusCode до разбора: goquery спокойно разберёт и страницу с ошибкой. Тип прокси определяется схемой адреса, поэтому socks5:// тоже работает со стандартным транспортом. Какой протокол заказать у провайдера, поможет решить сравнение SOCKS5 и HTTP-прокси, а как зашифрованный трафик проходит через прокси, объясняет статья про HTTP- и HTTPS-прокси.

Шаг 2. Обход сайта с Colly

Colly прячет цикл запросов за колбэками: OnHTML срабатывает на каждый элемент, подходящий под селектор, а вызов Visit внутри колбэка переходит по ссылке. LimitRule задаёт параллельность и случайную паузу для домена.

package main

import (
	"fmt"
	"log"
	"os"
	"time"

	"github.com/gocolly/colly/v2"
)

type Book struct {
	Title string `json:"title"`
	Price string `json:"price"`
	Link  string `json:"link"`
}

func main() {
	var shelf []Book

	crawler := colly.NewCollector(
		colly.AllowedDomains("books.toscrape.com"),
		colly.UserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"),
	)
	crawler.SetRequestTimeout(30 * time.Second)

	if err := crawler.SetProxy(os.Getenv("EXIT_URL")); err != nil {
		log.Fatal(err)
	}
	crawler.Limit(&colly.LimitRule{
		DomainGlob:  "*",
		Parallelism: 2,
		RandomDelay: 2 * time.Second,
	})

	crawler.OnHTML("article.product_pod", func(card *colly.HTMLElement) {
		shelf = append(shelf, Book{
			Title: card.ChildAttr("h3 a", "title"),
			Price: card.ChildText("p.price_color"),
			Link:  card.Request.AbsoluteURL(card.ChildAttr("h3 a", "href")),
		})
	})
	crawler.OnHTML("li.next a", func(next *colly.HTMLElement) {
		next.Request.Visit(next.Attr("href"))
	})
	crawler.OnError(func(r *colly.Response, err error) {
		log.Printf("failed %s: %d %v", r.Request.URL, r.StatusCode, err)
	})

	crawler.Visit("https://books.toscrape.com/catalogue/page-1.html")
	fmt.Println("books collected:", len(shelf))
}

Здесь коллектор работает синхронно, поэтому добавлять элементы в срез безопасно. Если включить colly.Async(true) ради скорости, защитите общие данные мьютексом и перед чтением результатов вызовите crawler.Wait().

Схема: одна программа запускает множество лёгких обработчиков, которые отправляют запросы к сайту через несколько выходных прокси
Параллельные обработчики делят небольшой пул прокси и лимит на сайт

Шаг 3. Ротация прокси в Colly и net/http

В Colly есть подпакет proxy с переключателем по кругу. Он принимает список адресов прокси и для каждого запроса берёт следующий:

import "github.com/gocolly/colly/v2/proxy"

switcher, err := proxy.RoundRobinProxySwitcher(
	os.Getenv("EXIT_URL_A"),
	os.Getenv("EXIT_URL_B"),
	os.Getenv("EXIT_URL_C"),
)
if err != nil {
	log.Fatal(err)
}
crawler.SetProxyFunc(switcher)

В обычном net/http поле Proxy у транспорта — это функция, так что выход можно выбирать на каждый запрос самостоятельно, например перебирая срез с атомарным счётчиком. На практике ротацию на стороне клиента часто вообще не делают: ротируемый шлюз выдаёт новый IP каждому новому соединению или держит один адрес для липкой сессии — режим задаётся логином или портом. Направьте все клиенты на один адрес ротируемых прокси, и смена IP происходит на стороне шлюза. Когда нужна смена на каждый запрос, а когда на сессию, разобрано в статье о ротации IP.

Помните, что Go переиспользует соединения. При keep-alive несколько запросов могут пройти через один туннель, а значит, через один выходной IP; если нужен новый адрес на каждый запрос, закрывайте простаивающие соединения или отключите keep-alive в транспорте.

Шаг 4. Страницы на JavaScript: chromedp

Если в браузере данные видны, а в скачанном HTML их нет, их дорисовывают скрипты. Сначала откройте DevTools, на вкладке Network включите фильтр Fetch/XHR и поищите JSON-запрос, который можно повторить через net/http, — это гораздо дешевле браузера. Если такого запроса нет, Chrome запускает chromedp.

Пример ниже использует классический API chromedp, который показывают большинство руководств: Run принимает действия и записывает результат в переменные. В README проекта сказано, что новые выпуски (начиная с v0.20) перешли на обобщённый API с итераторами, и дана ссылка на руководство по миграции, поэтому либо закрепите более раннюю версию в go.mod, либо перепишите вызовы по этому руководству.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"
	"time"

	"github.com/PuerkitoBio/goquery"
	"github.com/chromedp/chromedp"
)

func main() {
	flags := append(chromedp.DefaultExecAllocatorOptions[:],
		chromedp.ProxyServer("http://"+os.Getenv("EXIT_HOSTPORT")),
		chromedp.Flag("blink-settings", "imagesEnabled=false"),
	)
	allocCtx, stopAlloc := chromedp.NewExecAllocator(context.Background(), flags...)
	defer stopAlloc()

	tabCtx, stopTab := chromedp.NewContext(allocCtx)
	defer stopTab()
	tabCtx, stopTimer := context.WithTimeout(tabCtx, 45*time.Second)
	defer stopTimer()

	var markup string
	err := chromedp.Run(tabCtx,
		chromedp.Navigate("https://quotes.toscrape.com/js/"),
		chromedp.WaitVisible("div.quote", chromedp.ByQuery),
		chromedp.OuterHTML("body", &markup, chromedp.ByQuery),
	)
	if err != nil {
		log.Fatal(err)
	}

	page, _ := goquery.NewDocumentFromReader(strings.NewReader(markup))
	page.Find("div.quote span.text").Each(func(_ int, s *goquery.Selection) {
		fmt.Println(s.Text())
	})
}

Логин и пароль во флаге прокси-сервера Chrome игнорирует, поэтому в EXIT_HOSTPORT только хост и порт. Проще всего добавить IP сервера в белый список в кабинете провайдера или поднять локальный пересылающий прокси без пароля, который передаёт трафик на прокси с авторизацией. Можно отвечать на запрос авторизации через домен Fetch протокола DevTools, но такой код зависит от версии chromedp — проверьте его, прежде чем на него полагаться.

Шаг 5. Параллельность без перегрузки сайта

Горутины дешёвые, поэтому ограничение должно исходить от вас. Буферизованный канал работает как семафор и ограничивает число одновременных запросов к сайту:

gate := make(chan struct{}, 4)
var wg sync.WaitGroup

for _, target := range pages {
	wg.Add(1)
	go func(addr string) {
		defer wg.Done()
		gate <- struct{}{}
		defer func() { <-gate }()

		grab(addr)
		time.Sleep(time.Duration(800+rand.Intn(1200)) * time.Millisecond)
	}(target)
}
wg.Wait()

Четыре параллельных запроса со случайной паузой — разумный старт для небольшого сайта. Увеличивайте число, только пока время ответа и коды остаются нормальными, а при ответах 429 отступайте, ориентируясь на заголовок Retry-After. О темпе, заголовках и выборе типа IP подробнее рассказано в статье о прокси для парсинга.

Шаг 6. Сохраняем в CSV и JSON

func saveShelf(shelf []Book) error {
	sheet, err := os.Create("books.csv")
	if err != nil {
		return err
	}
	defer sheet.Close()
	w := csv.NewWriter(sheet)
	w.Write([]string{"title", "price", "link"})
	for _, b := range shelf {
		w.Write([]string{b.Title, b.Price, b.Link})
	}
	w.Flush()
	if err := w.Error(); err != nil {
		return err
	}

	blob, err := json.MarshalIndent(shelf, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile("books.json", blob, 0o644)
}

Теги структуры Book задают имена полей в JSON. csv.Writer буферизует вывод, поэтому Flush и проверка ошибки обязательны. Если файл будут открывать в Excel с русской локалью, задайте w.Comma = ';'.

Изометрическая иллюстрация: конвейер несёт карточки веб-страниц в сортировочную машину, на выходе — таблицы и структурированные документы
Разобранные записи выходят из конвейера файлами CSV и JSON

Частые ошибки

ОшибкаВероятная причинаРешение
407 Proxy Authentication RequiredНеверный логин, спецсимволы не закодированы в URLСобрать адрес через url.UserPassword, проверить белый список IP
proxyconnect tcp: ... connection refusedНеверный хост или порт, прокси недоступенПроверить host:port, попробовать прокси через curl
Forbidden domain в CollyАдрес вне AllowedDomainsДобавить домен или проверить, куда ведёт редирект
context deadline exceeded в chromedpСелектор так и не появился, медленный проксиПроверить селектор, увеличить тайм-аут
403 или страница проверкиIP под подозрением, заголовки по умолчаниюЗаголовки как у браузера, резидентные IP, медленнее темп
Один и тот же IP на все запросыkeep-alive переиспользует туннельОтключить keep-alive или включить режим смены IP на каждый запрос
Пустой результатКонтент рисуют скрипты, страница согласияСохранить HTML, искать XHR-запрос или использовать chromedp

Собирайте только общедоступные данные, до начала работы читайте robots.txt и правила сайта и не храните персональные данные, которые вам не нужны.

Частые вопросы о парсинге на Go

Подходит ли Go для парсинга сайтов?

Да, особенно для крупных обходов. Горутины делают высокую параллельность дешёвой, скомпилированный файл легко развернуть, а Colly, goquery и chromedp закрывают обход, разбор и отрисовку JavaScript, хотя готовых расширений у Python больше.

Чем Colly отличается от goquery?

goquery только разбирает уже полученный HTML и позволяет искать элементы по CSS-селекторам. Colly — фреймворк обхода: он скачивает страницы, переходит по ссылкам, соблюдает лимиты и вызывает ваши обработчики, а для селекторов сам использует goquery.

Как указать прокси в Colly?

Для одного выхода вызовите SetProxy с полным адресом прокси, а для ротации передайте в SetProxyFunc переключатель RoundRobinProxySwitcher из пакета colly/v2/proxy. Логин и пароль пишутся в адресе, например http://user:pass@host:port.

Можно ли на Go парсить сайты на JavaScript?

Да, с помощью chromedp, который управляет настоящим Chrome или Chromium через протокол DevTools. Но сначала проверьте, не берёт ли страница данные из JSON-запроса: обращаться к нему напрямую гораздо быстрее, чем отрисовывать страницу.

Сколько параллельных запросов делать парсеру на Go?

Начните с нескольких на сайт, например двух-четырёх, со случайными паузами и увеличивайте, только пока сервер отвечает быстро и без кодов 429 и 403. Предел задаёт сайт, а не Go.