Парсинг на Go: полное руководство от простого к сложному

Полное руководство по парсингу на Go: net/http, goquery, Colly, конкурентность через горутины и сборка быстрого краулера.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 10 марта 2025

Go — один из самых удобных языков для написания парсеров и краулеров: статическая типизация ловит ошибки на компиляции, горутины дают дешёвую конкурентность из коробки, а стандартная библиотека net/http закрывает бóльшую часть сетевой работы без сторонних зависимостей. В этой статье разберём весь путь — от загрузки одной страницы до распределённого многопоточного краулера с прокси, TOR и очередями.

Весь код проверялся на Go 1.21+. Сторонние пакеты ставятся через go get, точные команды приведены в соответствующих разделах.


Оглавление

  1. Как забираем страницу
  2. Библиотеки для парсинга содержимого
  3. Решение проблем с парсингом кириллицы
  4. Многопоточность
  5. Использование прокси
  6. Парсинг через TOR
  7. Работа с HTTPS / SSL
  8. Работа с cookie
  9. Статус ответа и заголовки
  10. Дополнительно: вежливость, robots.txt, User-Agent, JS-рендеринг, retry
  11. Хранение URL и очереди
  12. Плюсы и минусы реализации на Go
  13. Заключение

1. Как забираем страницу

Самый простой вариант

go
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, у которого нет таймаута. Для боевого парсера это недопустимо: один зависший сервер заблокирует горутину навсегда.

Правильный вариант: свой клиент с таймаутом и заголовками

go
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 соединений, поэтому создавайте его один раз на всё приложение, а не на каждый запрос.

go
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-подобный синтаксис (самый популярный)

bash
go get github.com/PuerkitoBio/goquery
go
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 — полноценный фреймворк-краулер

bash
go get github.com/gocolly/colly/v2

Colly берёт на себя загрузку, разбор, обход ссылок, ограничение скорости, кэширование и многое другое — это уже не просто парсер, а движок краулера.

go
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 — низкоуровневый токенизатор

Стандартный (полу-стандартный) пакет. Даёт максимум контроля и нулевые зависимости, но писать на нём руками утомительно — это потоковый разбор по токенам.

go
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:

go
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:

go
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.

bash
go get golang.org/x/net/html
go get golang.org/x/text
go
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:

go
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 (например, в старую форму):

go
encoder := charmap.Windows1251.NewEncoder()
encoded, _ := encoder.String("Привет мир")
// encoded теперь в байтах cp1251 — отправляем в теле запроса

Если у вас «кракозябры» только в консоли Windows, а в файл всё пишется нормально — проблема не в парсере, а в кодировке терминала. Выполните chcp 65001, чтобы переключить cmd на UTF-8.


4. Многопоточность

Здесь Go раскрывается во всей красе. Горутины дешевле потоков ОС в тысячи раз, а каналы дают безопасный обмен данными без явных мьютексов.

4.1. Наивный (неправильный) подход

go
// Плохо: запускаем горутину на КАЖДЫЙ url без ограничений
for _, url := range urls {
    go fetch(url) // 100 000 url → 100 000 одновременных запросов → сервер ляжет или забанит
}

Без ограничения параллелизма вы либо положите целевой сервер, либо исчерпаете лимит файловых дескрипторов, либо мгновенно получите бан.

4.2. Worker Pool — каноничный паттерн

Создаём фиксированное число воркеров, которые разбирают задачи из канала. Параллелизм ограничен числом воркеров.

go
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 с лимитом. Он же удобно собирает первую ошибку и поддерживает контекст.

bash
go get golang.org/x/sync/errgroup
go
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), её нужно защищать:

go
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.

Один прокси на клиент

go
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-функцию транспорта — она вызывается на каждый запрос:

go
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:

bash
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)

bash
go get golang.org/x/net/proxy
go
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:

go
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 — только для тестов:

go
import "crypto/tls"

transport := &http.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true, // НЕ для продакшена!
    },
}
client := &http.Client{Transport: transport}

2. Правильный способ — добавить конкретный корневой сертификат в пул доверия:

go
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 и шифров. Иногда сайт требует определённую конфигурацию или, наоборот, вы хотите имитировать конкретный браузер:

go
tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS12,
    MaxVersion: tls.VersionTLS13,
}

TLS-фингерпринтинг. Продвинутые анти-бот системы (Cloudflare, Akamai) определяют ботов по «отпечатку» TLS-рукопожатия (JA3/JA4) — стандартный Go-клиент имеет узнаваемый отпечаток. Чтобы маскироваться под реальный браузер, используют библиотеку github.com/refraction-networking/utls, которая умеет подделывать ClientHello под Chrome/Firefox. Это уже продвинутая тема антидетекта.


Cookie нужны для сессий, авторизации и «прохождения» проверок. Go умеет управлять ими автоматически через cookiejar.

go
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 из ответов и подставляет их в последующие запросы к тому же домену.

Когда токен сессии у вас уже есть (скопировали из браузера) и сессию надо просто «подставить»:

go
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)

go
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 содержит всю метаинформацию.

go
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)
}

Грамотная обработка статусов

go
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 редиректов
}

Управление редиректами

По умолчанию клиент следует за редиректами. Чтобы отключить или перехватить их:

go
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:

go
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:

go
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 на тысячи запросов — явный признак бота. Держите список и выбирайте случайный:

go
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, таймауты) нужно повторять, увеличивая паузу:

go
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 — более высокоуровневая альтернатива.
go
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 как множества посещённых:

go
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-фильтр — вероятностная структура, которая занимает мало памяти и быстро отвечает «точно не видел» или «возможно, видел» (с малой вероятностью ложного срабатывания):

go
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:

go
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 выглядит так:

  1. Один переиспользуемый http.Client с таймаутом, настроенным Transport и (опционально) cookiejar.
  2. Worker pool или errgroup с лимитом — для контролируемого параллелизма.
  3. goquery (или colly для обхода) — для извлечения данных, с charset.NewReader для корректной кириллицы.
  4. Прокси/TOR + ротация User-Agent — когда нужна анонимность или обход банов.
  5. Rate limiting и уважение к robots.txt — чтобы не положить сервер и не получить бан.
  6. Retry с backoff — для устойчивости к сбоям.
  7. Внешняя очередь (Redis) и дедупликация (Bloom-фильтр) — когда задача перерастает оперативную память.

Go даёт отличный баланс производительности, надёжности и простоты деплоя. Главный совет: всегда начинайте с проверки — нет ли у сайта открытого JSON-API. Если есть, половина проблем из этой статьи (кодировки, JS-рендеринг, хрупкие селекторы) отпадает сама собой.