Go — один из самых удобных языков для написания парсеров и краулеров: статическая типизация ловит ошибки на компиляции, горутины дают дешёвую конкурентность из коробки, а стандартная библиотека net/http закрывает бóльшую часть сетевой работы без сторонних зависимостей. В этой статье разберём весь путь — от загрузки одной страницы до распределённого многопоточного краулера с прокси, TOR и очередями.
Весь код проверялся на Go 1.21+. Сторонние пакеты ставятся через go get, точные команды приведены в соответствующих разделах.
Оглавление
- Как забираем страницу
- Библиотеки для парсинга содержимого
- Решение проблем с парсингом кириллицы
- Многопоточность
- Использование прокси
- Парсинг через TOR
- Работа с HTTPS / SSL
- Работа с cookie
- Статус ответа и заголовки
- Дополнительно: вежливость, robots.txt, User-Agent, JS-рендеринг, retry
- Хранение URL и очереди
- Плюсы и минусы реализации на Go
- Заключение
1. Как забираем страницу
Самый простой вариант
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("https://example.com")
if err != nil {
panic(err)
}
defer resp.Body.Close() // обязательно закрываем тело — иначе утечка соединений
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
http.Get использует глобальный http.DefaultClient, у которого нет таймаута. Для боевого парсера это недопустимо: один зависший сервер заблокирует горутину навсегда.
Правильный вариант: свой клиент с таймаутом и заголовками
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func fetch(ctx context.Context, rawURL string) ([]byte, *http.Response, error) {
client := &http.Client{
Timeout: 15 * time.Second, // общий таймаут на весь запрос
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, nil, err
}
// Многие сайты отдают 403 без человеческого User-Agent.
req.Header.Set("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return body, resp, err
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
body, resp, err := fetch(ctx, "https://example.com")
if err != nil {
panic(err)
}
fmt.Println("Статус:", resp.StatusCode, "| Размер:", len(body))
}
Ключевые моменты:
context.Context— единый механизм отмены. Если контекст истёк или его отменили, запрос прерывается. Это спасает при graceful shutdown и при ограничении времени на весь обход.Timeoutу клиента ограничивает всё: установку соединения, отправку, чтение тела. Можно настроить точнее через свойhttp.Transport(см. ниже).defer resp.Body.Close()— не закрытое тело держит TCP-соединение и не даёт переиспользовать его из пула. На больших объёмах это приводит к ошибке «too many open files».
Тонкая настройка Transport
http.Transport — это «движок» под клиентом. Один экземпляр транспорта переиспользуется между запросами и держит пул keep-alive соединений, поэтому создавайте его один раз на всё приложение, а не на каждый запрос.
transport := &http.Transport{
MaxIdleConns: 100, // всего idle-соединений в пуле
MaxIdleConnsPerHost: 10, // на один хост
IdleConnTimeout: 90 * time.Second, // сколько живёт idle-соединение
DisableCompression: false, // gzip распакуется автоматически
ForceAttemptHTTP2: true,
}
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
Частая ошибка — создавать
&http.Client{}(или транспорт) внутри цикла на каждый URL. Это ломает пул соединений и приводит к исчерпанию портов. Создавайте клиент один раз и передавайте по ссылке.
2. Библиотеки для парсинга содержимого
Когда HTML загружен, его нужно разобрать. Есть три уровня инструментов.
2.1. goquery — jQuery-подобный синтаксис (самый популярный)
go get github.com/PuerkitoBio/goquery
package main
import (
"fmt"
"net/http"
"github.com/PuerkitoBio/goquery"
)
func main() {
resp, _ := http.Get("https://news.ycombinator.com")
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
panic(err)
}
// CSS-селекторы как в jQuery
doc.Find(".titleline > a").Each(func(i int, s *goquery.Selection) {
title := s.Text()
href, _ := s.Attr("href")
fmt.Printf("%d. %s — %s\n", i+1, title, href)
})
}
goquery поддерживает почти весь CSS3: .class, #id, [attr=value], :first-child, :nth-of-type(n), комбинаторы >, +, ~. Это лучший выбор для большинства задач разбора HTML.
2.2. colly — полноценный фреймворк-краулер
go get github.com/gocolly/colly/v2
Colly берёт на себя загрузку, разбор, обход ссылок, ограничение скорости, кэширование и многое другое — это уже не просто парсер, а движок краулера.
package main
import (
"fmt"
"time"
"github.com/gocolly/colly/v2"
)
func main() {
c := colly.NewCollector(
colly.AllowedDomains("example.com"),
colly.MaxDepth(2),
colly.Async(true), // асинхронный обход
)
// Ограничение параллелизма и задержка — встроенная вежливость
c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 4,
Delay: 500 * time.Millisecond,
RandomDelay: 500 * time.Millisecond,
})
// Колбэк на каждый найденный заголовок
c.OnHTML("h1, h2", func(e *colly.HTMLElement) {
fmt.Println("Заголовок:", e.Text)
})
// Переход по всем ссылкам
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
link := e.Request.AbsoluteURL(e.Attr("href"))
e.Request.Visit(link)
})
c.OnRequest(func(r *colly.Request) {
fmt.Println("Загружаю:", r.URL)
})
c.OnError(func(r *colly.Response, err error) {
fmt.Println("Ошибка:", r.Request.URL, err)
})
c.Visit("https://example.com")
c.Wait() // ждём завершения всех асинхронных запросов
}
Colly стоит выбирать, когда нужно именно обходить сайт (краулинг), а не разобрать одну страницу.
2.3. golang.org/x/net/html — низкоуровневый токенизатор
Стандартный (полу-стандартный) пакет. Даёт максимум контроля и нулевые зависимости, но писать на нём руками утомительно — это потоковый разбор по токенам.
package main
import (
"fmt"
"strings"
"golang.org/x/net/html"
)
func main() {
r := strings.NewReader(`<html><body><a href="/x">Ссылка</a></body></html>`)
tokenizer := html.NewTokenizer(r)
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break // конец документа
}
if tt == html.StartTagToken {
t := tokenizer.Token()
if t.Data == "a" {
for _, a := range t.Attr {
if a.Key == "href" {
fmt.Println("href:", a.Val)
}
}
}
}
}
}
Используйте его, когда важна скорость на огромных документах или когда goquery «тяжеловат».
2.4. JSON и API вместо HTML
Часто данные на странице грузятся отдельным AJAX-запросом, отдающим JSON. Это самый удобный случай — парсить JSON надёжнее, чем HTML:
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
var products []Product
resp, _ := http.Get("https://api.example.com/products")
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&products)
Перед тем как парсить HTML, откройте вкладку Network в DevTools — возможно, нужный JSON-эндпоинт уже существует.
2.5. XPath
Если привычнее XPath, есть github.com/antchfx/htmlquery:
doc, _ := htmlquery.LoadURL("https://example.com")
nodes := htmlquery.Find(doc, "//div[@class='item']/a/@href")
Сравнение
| Инструмент | Когда брать | Зависимости |
|---|---|---|
goquery |
Разбор HTML, CSS-селекторы | 1 |
colly |
Обход сайтов целиком | несколько |
x/net/html |
Максимум контроля/скорости | semi-std |
encoding/json |
API/AJAX отдают JSON | std |
htmlquery |
Любите XPath | 1 |
3. Решение проблем с парсингом кириллицы
Классическая боль: загрузили русский сайт, а вместо текста — пÑÐ¸Ð²ÐµÑ или пðèâåò. Причина в том, что сайт отдаёт контент не в UTF-8, а в Windows-1251 (или KOI8-R), а Go по умолчанию считает все байты строки за UTF-8.
Универсальное решение: charset-детектор
Пакет golang.org/x/net/html/charset определяет кодировку по заголовку Content-Type, по <meta charset> и эвристически по содержимому, после чего отдаёт reader, который на лету перекодирует поток в UTF-8.
go get golang.org/x/net/html
go get golang.org/x/text
package main
import (
"fmt"
"io"
"net/http"
"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html/charset"
)
func main() {
resp, _ := http.Get("https://some-windows1251-site.ru")
defer resp.Body.Close()
// charset.NewReader сам определит кодировку и перекодирует в UTF-8
utf8Reader, err := charset.NewReader(resp.Body, resp.Header.Get("Content-Type"))
if err != nil {
panic(err)
}
doc, err := goquery.NewDocumentFromReader(utf8Reader)
if err != nil {
panic(err)
}
fmt.Println(doc.Find("title").Text()) // теперь кириллица корректна
_ = io.Discard
}
Это решение работает в 95% случаев — рекомендуется ставить его по умолчанию.
Явное указание кодировки
Если вы точно знаете кодировку (например, сайт всегда в Windows-1251), можно перекодировать вручную через golang.org/x/text/encoding:
import (
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
// Windows-1251 → UTF-8
decoder := charmap.Windows1251.NewDecoder()
reader := transform.NewReader(resp.Body, decoder)
body, _ := io.ReadAll(reader)
fmt.Println(string(body))
Для KOI8-R — charmap.KOI8R, для CP866 (DOS) — charmap.CodePage866.
Обратная задача — отправка кириллицы
Если нужно отправить POST с кириллицей в Windows-1251 (например, в старую форму):
encoder := charmap.Windows1251.NewEncoder()
encoded, _ := encoder.String("Привет мир")
// encoded теперь в байтах cp1251 — отправляем в теле запроса
Если у вас «кракозябры» только в консоли Windows, а в файл всё пишется нормально — проблема не в парсере, а в кодировке терминала. Выполните
chcp 65001, чтобы переключить cmd на UTF-8.
4. Многопоточность
Здесь Go раскрывается во всей красе. Горутины дешевле потоков ОС в тысячи раз, а каналы дают безопасный обмен данными без явных мьютексов.
4.1. Наивный (неправильный) подход
// Плохо: запускаем горутину на КАЖДЫЙ url без ограничений
for _, url := range urls {
go fetch(url) // 100 000 url → 100 000 одновременных запросов → сервер ляжет или забанит
}
Без ограничения параллелизма вы либо положите целевой сервер, либо исчерпаете лимит файловых дескрипторов, либо мгновенно получите бан.
4.2. Worker Pool — каноничный паттерн
Создаём фиксированное число воркеров, которые разбирают задачи из канала. Параллелизм ограничен числом воркеров.
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
type Result struct {
URL string
Status int
Size int
Err error
}
func worker(id int, client *http.Client, jobs <-chan string, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for url := range jobs {
resp, err := client.Get(url)
if err != nil {
results <- Result{URL: url, Err: err}
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
results <- Result{URL: url, Status: resp.StatusCode, Size: len(body)}
}
}
func main() {
urls := []string{
"https://example.com",
"https://golang.org",
"https://news.ycombinator.com",
// ... тысячи url
}
const numWorkers = 8
client := &http.Client{Timeout: 10 * time.Second}
jobs := make(chan string, 100)
results := make(chan Result, 100)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, client, jobs, results, &wg)
}
// Подаём задачи в отдельной горутине
go func() {
for _, u := range urls {
jobs <- u
}
close(jobs) // важно: закрываем канал, чтобы воркеры завершились
}()
// Закрываем results, когда все воркеры доделали
go func() {
wg.Wait()
close(results)
}()
// Читаем результаты
for r := range results {
if r.Err != nil {
fmt.Printf("[err] %s: %v\n", r.URL, r.Err)
} else {
fmt.Printf("[ok] %s [%d] %d bytes\n", r.URL, r.Status, r.Size)
}
}
}
Разбор паттерна:
jobs— канал входных задач. Закрываем после подачи всех URL — это сигнал воркерам завершить циклfor range.sync.WaitGroup— ждём, пока все воркеры закончат.resultsзакрывается отдельной горутиной послеwg.Wait(), иначе главныйfor range resultsзависнет навсегда.- Параллелизм контролируется одной константой
numWorkers.
4.3. Ограничение через семафор (errgroup)
Более современный способ — golang.org/x/sync/errgroup с лимитом. Он же удобно собирает первую ошибку и поддерживает контекст.
go get golang.org/x/sync/errgroup
package main
import (
"context"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
urls := []string{"https://example.com", "https://golang.org" /* ... */}
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(8) // максимум 8 одновременных горутин
client := &http.Client{}
for _, u := range urls {
u := u // важно для Go < 1.22: захват переменной цикла
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
fmt.Println(u, resp.StatusCode)
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("Одна из задач упала:", err)
}
}
В Go до 1.22 переменная цикла переиспользуется, поэтому строка
u := uобязательна — иначе все горутины получат один и тот же (последний) URL. В Go 1.22+ это исправлено на уровне языка, но привычка не вредит.
4.4. Защита общих данных
Если воркеры пишут в общую map (например, набор посещённых URL), её нужно защищать:
var (
visited = make(map[string]bool)
mu sync.Mutex
)
func markVisited(url string) bool {
mu.Lock()
defer mu.Unlock()
if visited[url] {
return false // уже был
}
visited[url] = true
return true
}
Альтернатива — sync.Map (хороша при «много чтений, мало записей») или sync/atomic для счётчиков. Запускайте тесты с флагом -race — детектор гонок Go находит такие баги автоматически.
5. Использование прокси
Прокси нужны для распределения нагрузки, обхода гео-блокировок и снижения вероятности бана по IP.
Один прокси на клиент
package main
import (
"net/http"
"net/url"
)
func clientWithProxy(proxyAddr string) (*http.Client, error) {
// Поддерживается user:pass@host:port
proxyURL, err := url.Parse(proxyAddr) // напр. "http://user:pass@1.2.3.4:8080"
if err != nil {
return nil, err
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
return &http.Client{Transport: transport}, nil
}
Поддерживаются схемы http://, https:// и socks5://. Для аутентификации логин и пароль кладутся прямо в URL.
Ротация пула прокси
Чтобы распределять запросы по списку прокси, можно подменять Proxy-функцию транспорта — она вызывается на каждый запрос:
package main
import (
"math/rand"
"net/http"
"net/url"
"sync/atomic"
)
type ProxyRotator struct {
proxies []*url.URL
counter uint64
}
func NewProxyRotator(addrs []string) *ProxyRotator {
r := &ProxyRotator{}
for _, a := range addrs {
if u, err := url.Parse(a); err == nil {
r.proxies = append(r.proxies, u)
}
}
return r
}
// Round-robin выбор прокси
func (r *ProxyRotator) Next(_ *http.Request) (*url.URL, error) {
if len(r.proxies) == 0 {
return nil, nil // без прокси
}
i := atomic.AddUint64(&r.counter, 1)
return r.proxies[i%uint64(len(r.proxies))], nil
}
func main() {
rotator := NewProxyRotator([]string{
"http://user:pass@10.0.0.1:8080",
"http://user:pass@10.0.0.2:8080",
"socks5://10.0.0.3:1080",
})
transport := &http.Transport{
Proxy: rotator.Next, // на каждый запрос — следующий прокси
}
client := &http.Client{Transport: transport}
_ = client
_ = rand.Int
}
На практике стоит хранить рядом с прокси его «здоровье»: счётчик ошибок, время последнего бана. Сдохший прокси временно исключается из ротации. Для этого обычно пишут небольшую обёртку, проверяющую прокси на «живой» эндпоинт перед использованием.
6. Парсинг через TOR
TOR — это бесплатная анонимизирующая сеть, доступная локально как SOCKS5-прокси (по умолчанию на 127.0.0.1:9050). Парсинг через TOR полезен для анонимности и автоматической смены IP, но он медленный и многие сайты блокируют выходные узлы TOR.
Подготовка
Запустите TOR-демон. Проще всего через Docker:
docker run -d --name tor -p 9050:9050 -p 9051:9051 dperson/torproxy
Либо установите системный пакет tor (apt install tor, brew install tor) — он сам поднимет SOCKS5 на 9050.
HTTP-клиент через TOR (SOCKS5)
go get golang.org/x/net/proxy
package main
import (
"fmt"
"io"
"net/http"
"golang.org/x/net/proxy"
)
func torClient() (*http.Client, error) {
// Подключаемся к локальному SOCKS5 TOR
dialer, err := proxy.SOCKS5("tcp", "127.0.0.1:9050", nil, proxy.Direct)
if err != nil {
return nil, err
}
transport := &http.Transport{
Dial: dialer.Dial, // весь трафик идёт через TOR
}
return &http.Client{Transport: transport}, nil
}
func main() {
client, err := torClient()
if err != nil {
panic(err)
}
// check.torproject.org подтвердит, что мы в TOR
resp, err := client.Get("https://check.torproject.org/api/ip")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body)) // {"IsTor":true,"IP":"..."}
}
Смена IP (новая цепочка) через control-порт
TOR умеет по команде строить новую цепочку — то есть менять выходной IP. Это делается через control-порт (9051), отправкой сигнала NEWNYM:
package main
import (
"fmt"
"net/textproto"
)
func newTorIdentity(controlPassword string) error {
conn, err := textproto.Dial("tcp", "127.0.0.1:9051")
if err != nil {
return err
}
defer conn.Close()
// Аутентификация (пароль должен быть настроен в torrc)
if _, _, err := conn.Cmd(`AUTHENTICATE "%s"`, controlPassword); err != nil {
return err
}
conn.ReadResponse(250)
// Сигнал на смену цепочки
id, _ := conn.Cmd("SIGNAL NEWNYM")
conn.StartResponse(id)
defer conn.EndResponse(id)
_, msg, err := conn.ReadResponse(250)
fmt.Println("TOR ответил:", msg)
return err
}
Чтобы control-порт работал, в torrc должно быть задано ControlPort 9051 и хэш пароля (HashedControlPassword, генерируется командой tor --hash-password ВАШ_ПАРОЛЬ).
TOR даёт анонимность, но не невидимость. Скорость низкая, выходные узлы часто в чёрных списках, а агрессивный парсинг через TOR — плохой тон по отношению к волонтёрской сети. Для массового сбора лучше подходят коммерческие резидентные прокси.
7. Работа с HTTPS / SSL
Хорошая новость: для HTTPS обычно не нужно делать ничего — Go проверяет сертификаты автоматически, используя системное хранилище доверенных корней.
Когда вмешательство всё же нужно
1. Самоподписанные сертификаты (тестовые/внутренние сайты). Отключение проверки решает проблему, но открывает дыру для MITM — только для тестов:
import "crypto/tls"
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // НЕ для продакшена!
},
}
client := &http.Client{Transport: transport}
2. Правильный способ — добавить конкретный корневой сертификат в пул доверия:
package main
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
)
func clientWithCustomCA(caCertPath string) (*http.Client, error) {
caCert, err := os.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
transport := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caPool,
MinVersion: tls.VersionTLS12, // не ниже TLS 1.2
},
}
return &http.Client{Transport: transport}, nil
}
3. Контроль версии TLS и шифров. Иногда сайт требует определённую конфигурацию или, наоборот, вы хотите имитировать конкретный браузер:
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS13,
}
TLS-фингерпринтинг. Продвинутые анти-бот системы (Cloudflare, Akamai) определяют ботов по «отпечатку» TLS-рукопожатия (JA3/JA4) — стандартный Go-клиент имеет узнаваемый отпечаток. Чтобы маскироваться под реальный браузер, используют библиотеку
github.com/refraction-networking/utls, которая умеет подделывать ClientHello под Chrome/Firefox. Это уже продвинутая тема антидетекта.
8. Работа с cookie
Cookie нужны для сессий, авторизации и «прохождения» проверок. Go умеет управлять ими автоматически через cookiejar.
Автоматическое хранение cookie
package main
import (
"fmt"
"net/http"
"net/http/cookiejar"
"golang.org/x/net/publicsuffix"
)
func main() {
// publicsuffix-список нужен для корректной обработки доменов
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
panic(err)
}
client := &http.Client{Jar: jar}
// Первый запрос: сервер ставит cookie (напр. сессию)
client.Get("https://example.com/login")
// Второй запрос автоматически отправит сохранённые cookie
resp, _ := client.Get("https://example.com/dashboard")
defer resp.Body.Close()
// Посмотреть, что в банке для конкретного хоста
u, _ := resp.Request.URL.Parse("https://example.com")
for _, c := range jar.Cookies(u) {
fmt.Printf("%s = %s\n", c.Name, c.Value)
}
}
С Jar клиент ведёт себя как браузер: принимает Set-Cookie из ответов и подставляет их в последующие запросы к тому же домену.
Ручная установка cookie
Когда токен сессии у вас уже есть (скопировали из браузера) и сессию надо просто «подставить»:
req, _ := http.NewRequest("GET", "https://example.com/account", nil)
req.AddCookie(&http.Cookie{Name: "session_id", Value: "abc123xyz"})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "tok456"})
resp, _ := client.Do(req)
Типичный сценарий авторизации (логин через POST)
import (
"net/url"
"strings"
)
form := url.Values{}
form.Set("username", "user")
form.Set("password", "pass")
req, _ := http.NewRequest("POST", "https://example.com/login",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Если у клиента есть Jar — сессионная cookie сохранится автоматически,
// и все последующие запросы будут авторизованными.
resp, _ := client.Do(req)
Сохранение банка cookie между запусками программы делается вручную: проходим по
jar.Cookies(url), сериализуем в JSON-файл, при старте загружаем обратно черезjar.SetCookies(url, cookies). Стандартныйcookiejarне персистентный, но есть готовые обёртки (github.com/juju/persistent-cookiejar).
9. Статус ответа и заголовки
После выполнения запроса объект *http.Response содержит всю метаинформацию.
resp, err := client.Do(req)
if err != nil {
// Сетевая ошибка (DNS, таймаут, отказ в соединении).
// ВАЖНО: при err != nil resp == nil — не трогаем resp.Body!
return err
}
defer resp.Body.Close()
// Статус
fmt.Println(resp.StatusCode) // 200, 404, 503 ...
fmt.Println(resp.Status) // "200 OK", "404 Not Found"
// Отдельные заголовки (регистр не важен)
fmt.Println(resp.Header.Get("Content-Type")) // text/html; charset=utf-8
fmt.Println(resp.Header.Get("Content-Length"))
fmt.Println(resp.Header.Get("Server"))
fmt.Println(resp.Header.Get("Set-Cookie"))
// Один заголовок может иметь несколько значений
for _, v := range resp.Header.Values("Set-Cookie") {
fmt.Println("cookie:", v)
}
// Все заголовки разом
for name, values := range resp.Header {
fmt.Printf("%s: %v\n", name, values)
}
Грамотная обработка статусов
switch {
case resp.StatusCode == http.StatusOK: // 200
// парсим тело
case resp.StatusCode == http.StatusTooManyRequests: // 429
// нас тормозят — читаем заголовок Retry-After и ждём
retryAfter := resp.Header.Get("Retry-After")
fmt.Println("Rate limit, ждём:", retryAfter)
case resp.StatusCode >= 500: // 5xx — серверная ошибка
// имеет смысл повторить запрос позже
case resp.StatusCode == http.StatusNotFound: // 404
// страница не существует — больше не пытаемся
case resp.StatusCode >= 300 && resp.StatusCode < 400: // 3xx
// редирект; по умолчанию клиент Go сам ходит до 10 редиректов
}
Управление редиректами
По умолчанию клиент следует за редиректами. Чтобы отключить или перехватить их:
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Запрещаем редиректы — вернётся ответ 3xx «как есть»
return http.ErrUseLastResponse
},
}
Запомните разницу: сетевая ошибка (
err != nil) — это когда запрос вообще не дошёл/не вернулся (DNS, таймаут). HTTP-ошибка (resp.StatusCode == 404/500) — это валидный ответ сервера, и при нейerr == nil. Проверять статус-код нужно всегда отдельно отerr.
10. Дополнительно: важные вещи, которые часто забывают
Эти разделы не были в исходном списке, но без них боевой парсер развалится.
10.1. Вежливость и rate limiting
Не бомбите сервер запросами — это и нагрузка на чужую инфраструктуру, и быстрый бан. Ограничивайте частоту через golang.org/x/time/rate:
import "golang.org/x/time/rate"
// 2 запроса в секунду, всплеск до 5
limiter := rate.NewLimiter(rate.Limit(2), 5)
func politeGet(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
if err := limiter.Wait(ctx); err != nil { // блокируется, пока не «можно»
return nil, err
}
return client.Get(url)
}
10.2. robots.txt
Хороший тон (а иногда и юридическая необходимость) — уважать robots.txt, где сайт указывает, что можно индексировать. Парсить его помогает github.com/temoto/robotstxt:
import "github.com/temoto/robotstxt"
resp, _ := http.Get("https://example.com/robots.txt")
data, _ := io.ReadAll(resp.Body)
robots, _ := robotstxt.FromBytes(data)
if robots.TestAgent("/private/page", "MyBot") {
// разрешено — парсим
} else {
// запрещено robots.txt
}
10.3. Ротация User-Agent
Один и тот же User-Agent на тысячи запросов — явный признак бота. Держите список и выбирайте случайный:
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/124.0 ...",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Safari/605.1 ...",
"Mozilla/5.0 (X11; Linux x86_64) ... Firefox/126.0",
}
req.Header.Set("User-Agent", userAgents[rand.Intn(len(userAgents))])
10.4. Retry с экспоненциальной задержкой
Сеть нестабильна — временные сбои (5xx, таймауты) нужно повторять, увеличивая паузу:
func fetchWithRetry(ctx context.Context, client *http.Client, url string, maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// 1s, 2s, 4s, 8s... + немного случайности (jitter)
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
jitter := time.Duration(rand.Intn(500)) * time.Millisecond
select {
case <-time.After(backoff + jitter):
case <-ctx.Done():
return nil, ctx.Err()
}
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue // повторяем при сетевой ошибке
}
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
resp.Body.Close()
lastErr = fmt.Errorf("статус %d", resp.StatusCode)
continue // повторяем при 5xx/429
}
return resp, nil // успех
}
return nil, fmt.Errorf("исчерпаны попытки: %w", lastErr)
}
10.5. Парсинг страниц с JavaScript
Если контент рисуется JavaScript-ом (SPA на React/Vue), http.Get вернёт почти пустой HTML-каркас. Тогда нужен headless-браузер, управляющий настоящим Chrome по протоколу CDP:
github.com/chromedp/chromedp— управление Chrome из Go;github.com/go-rod/rod— более высокоуровневая альтернатива.
import "github.com/chromedp/chromedp"
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var html string
chromedp.Run(ctx,
chromedp.Navigate("https://spa-example.com"),
chromedp.WaitVisible(".content"), // ждём, пока JS отрисует
chromedp.OuterHTML("html", &html), // забираем готовый DOM
)
// дальше html скармливаем в goquery
Минус: headless-браузер тяжёлый (память, CPU) и медленный. Прежде чем тянуть Chrome, проверьте — возможно, данные доступны через JSON-API (см. раздел 2.4), и браузер не нужен.
10.6. Юридические и этические аспекты
Парсинг — серая зона. Базовые ориентиры: уважайте robots.txt и Terms of Service сайта; не собирайте персональные данные без основания (GDPR/152-ФЗ); не создавайте чрезмерную нагрузку; не выдавайте чужой контент за свой. Технически возможное и юридически допустимое — не одно и то же.
11. Хранение URL и очереди
Для краулера нужны две структуры: очередь ещё не обойдённых URL (frontier) и множество уже посещённых (чтобы не ходить по кругу).
11.1. In-memory (простейший вариант)
Для небольших задач хватает канала как очереди и map как множества посещённых:
type Crawler struct {
queue chan string
visited map[string]bool
mu sync.Mutex
}
func (c *Crawler) enqueue(url string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.visited[url] {
return // уже видели — пропускаем
}
c.visited[url] = true
select {
case c.queue <- url:
default: // очередь переполнена — отбрасываем или копим отдельно
}
}
Проблемы in-memory: при перезапуске всё теряется, а на миллионах URL map съест всю память.
11.2. Дедупликация большими объёмами: Bloom-фильтр
Хранить десятки миллионов строк в map дорого. Bloom-фильтр — вероятностная структура, которая занимает мало памяти и быстро отвечает «точно не видел» или «возможно, видел» (с малой вероятностью ложного срабатывания):
import "github.com/bits-and-blooms/bloom/v3"
// ~10 млн элементов, вероятность ошибки 1%
filter := bloom.NewWithEstimates(10_000_000, 0.01)
if filter.TestString(url) {
// возможно, уже видели — пропускаем (с риском редко пропустить новый)
} else {
filter.AddString(url)
// точно новый — в очередь
}
11.3. Внешние очереди (продакшен, распределённость)
Когда краулер должен переживать рестарты и работать на нескольких машинах, очередь выносят во внешнее хранилище:
| Хранилище | Роль | Особенности |
|---|---|---|
| Redis | очередь (LPUSH/BRPOP) + множество посещённых (SET/SADD) |
быстрый, атомарный, идеален для распределённых воркеров |
| RabbitMQ / Kafka | очередь задач | надёжная доставка, ack/nack, переотправка упавших |
| PostgreSQL / SQLite | персистентный frontier | удобно хранить URL + метаданные + статус |
| BadgerDB / bbolt | встроенное KV-хранилище | без отдельного сервера, всё в одном бинарнике |
Пример очереди на Redis:
import "github.com/redis/go-redis/v9"
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// Добавить URL в очередь, только если ещё не посещён (атомарно через SET)
func enqueue(ctx context.Context, url string) error {
// SADD вернёт 1, если элемент новый
added, err := rdb.SAdd(ctx, "visited", url).Result()
if err != nil {
return err
}
if added == 1 {
return rdb.LPush(ctx, "frontier", url).Err()
}
return nil // дубликат
}
// Взять URL из очереди (блокирующе)
func dequeue(ctx context.Context) (string, error) {
res, err := rdb.BRPop(ctx, 5*time.Second, "frontier").Result()
if err != nil {
return "", err
}
return res[1], nil // res[0] — имя ключа, res[1] — значение
}
Такая схема позволяет запустить десятки воркеров на разных машинах: все берут задачи из общей очереди Redis и пишут результаты в общую БД, не дублируя работу.
11.4. Приоритеты и стратегия обхода
- BFS (очередь — обычный FIFO) — обходит сайт «вширь», обычно предпочтительнее для краулинга.
- DFS (стек — LIFO) — уходит вглубь по одной ветке.
- Приоритетная очередь (
container/heapили Redis Sorted Set) — сначала обходим важные страницы (например, по «глубине» или по предполагаемой ценности).
12. Плюсы и минусы реализации на Go
Плюсы
- Конкурентность из коробки. Горутины и каналы делают многопоточный краулер естественным и дешёвым. Тысячи параллельных запросов — без боли с пулами потоков.
- Производительность. Компилируется в нативный код, быстрый и экономный по памяти. Парсер на Go обгоняет аналог на Python в разы по пропускной способности.
- Один бинарник.
go buildдаёт самодостаточный исполняемый файл без зависимостей и интерпретатора — деплой тривиален, удобно для Docker и cron. - Сильная стандартная библиотека.
net/http,crypto/tls,cookiejar,context,encoding/jsonпокрывают почти всё без сторонних пакетов. - Статическая типизация. Многие ошибки ловятся на компиляции, а не в рантайме посреди многочасового обхода.
- Встроенный детектор гонок (
-race) — незаменим в многопоточном коде. - Зрелая экосистема: colly, goquery, chromedp — проверенные временем инструменты.
Минусы
- Многословность парсинга HTML. По сравнению с Python (
requests+BeautifulSoupв 5 строк) Go требует больше кода и явной обработки ошибок. - Слабее для headless-браузеров. Python с Playwright/Selenium богаче на возможности; chromedp/rod хороши, но экосистема скромнее.
- Анти-детект сложнее. Подделка TLS-фингерпринта (utls) и эмуляция браузера требуют больше усилий, чем готовые решения в других стеках.
- Ручная обработка ошибок. Постоянные
if err != nilутомляют, хотя и дисциплинируют. - Меньше готовых ML/NLP-инструментов для постобработки извлечённого текста — тут Python всё ещё лидирует. Частый паттерн: сбор данных на Go, аналитика на Python.
- Порог входа в конкурентность. Каналы, дедлоки, гонки — мощно, но требует понимания; новичок легко напишет утечку горутин.
Когда брать Go, а когда нет
| Сценарий | Рекомендация |
|---|---|
| Высоконагруженный краулер, миллионы страниц | Go — идеален |
| Долгоживущий сервис-парсер | Go |
| Разовый скрипт «спарсить табличку» | Python быстрее напишется |
| Тяжёлый JS-рендеринг, сложный антидетект | Часто удобнее Python + Playwright |
| Парсинг + сразу ML-анализ | Python ближе к данным |
13. Заключение
Минимальная схема боевого парсера на Go выглядит так:
- Один переиспользуемый
http.Clientс таймаутом, настроеннымTransportи (опционально)cookiejar. - Worker pool или
errgroupс лимитом — для контролируемого параллелизма. - goquery (или colly для обхода) — для извлечения данных, с
charset.NewReaderдля корректной кириллицы. - Прокси/TOR + ротация User-Agent — когда нужна анонимность или обход банов.
- Rate limiting и уважение к
robots.txt— чтобы не положить сервер и не получить бан. - Retry с backoff — для устойчивости к сбоям.
- Внешняя очередь (Redis) и дедупликация (Bloom-фильтр) — когда задача перерастает оперативную память.
Go даёт отличный баланс производительности, надёжности и простоты деплоя. Главный совет: всегда начинайте с проверки — нет ли у сайта открытого JSON-API. Если есть, половина проблем из этой статьи (кодировки, JS-рендеринг, хрупкие селекторы) отпадает сама собой.