Go Proxy Scraping: http.Transport, аутентификация, ротация и параллелизм

В Go вся история с прокси сосредоточена в одном поле: Transport.Proxy. Настройте его правильно, и вы получите ротацию для каждого запроса, SOCKS5 и colly бесплатно. Настройте неправильно, и вы получите 'context canceled' в 2 часа ночи.

Go делает проксирование обманчиво простым: вся клиентская история сосредоточена в одном поле, http.Transport.Proxy. Настройте его правильно, и ротация для каждого запроса, SOCKS5 и интеграция с colly будут работать по одному и тому же принципу. Настройте неправильно, и вы столкнетесь с классическими ошибками Go прокси - context canceled, истощением пула соединений и переменной HTTP_PROXY, которую ваш пользовательский транспорт тихо игнорирует. Это руководство проведет вас по всему пути с исполняемым кодом.

Однострочник: http.Transport с URL прокси

Каждый запрос через прокси в Go проходит через Transport. Пакет net/http содержит помощник, http.ProxyURL, который закрепляет один статический прокси для всего клиента. Учетные данные вставляются прямо в URL userinfo - Go преобразует их в заголовок Proxy-Authorization за вас:

package main

import (
  "fmt"
  "net/http"
  "net/url"
  "time"
)

func main() {
  proxyURL, _ := url.Parse("http://USER:PASS@gate.quantumproxies.io:8000")
  client := &http.Client{
    Timeout:   20 * time.Second,
    Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
  }
  resp, err := client.Get("https://httpbin.org/ip")
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()
  fmt.Println(resp.Status) // body shows the exit IP, not yours
}

Всегда устанавливайте Client.Timeout. В Go по умолчанию нет тайм-аута на http.Client, поэтому один зависший выход заблокирует горутину навсегда - главная причина, по которой Go-скрапер кажется зависшим, это отсутствие тайм-аута, а не медленная цель.

Ротация прокси с помощью функции Proxy

Proxy не ограничивается фиксированным URL - это функция func(*http.Request) (*url.URL, error), которую Go вызывает один раз на каждый запрос. Это ваш крючок для ротации. Выберите случайную конечную точку из пула, и каждый запрос будет выходить через другой IP, без какой-либо логики управления списком в вашем цикле выборки:

import "math/rand"

pool := []string{
  "http://USER:PASS@ip1.quantumproxies.io:8000",
  "http://USER:PASS@ip2.quantumproxies.io:8000",
  "http://USER:PASS@ip3.quantumproxies.io:8000",
}

transport := &http.Transport{
  Proxy: func(r *http.Request) (*url.URL, error) {
    return url.Parse(pool[rand.Intn(len(pool))])
  },
  MaxIdleConnsPerHost: 32, // reuse connections across the pool
}

Для большинства скрапинга вам не следует управлять пулом вообще. Направьте функцию Proxy на один ротационный резидентный шлюз, и шлюз предоставит вам новый IP на каждый запрос из более чем 90 миллионов адресов в более чем 200 странах - одна конечная точка, без списка, липкие сессии, когда поток требует одного выхода на несколько минут.

Диаграмма, показывающая, как Go http.Client делегирует запрос http.Transport, который вызывает функцию Proxy для маршрутизации через ротационный резидентный шлюз на целевой сайт
Один Transport, одна функция Proxy: функция выполняется для каждого запроса, так что один клиент вращается по всему пулу.

Аутентификация прокси в Go

Вставка USER:PASS@ в URL прокси - это чистый путь и работает как для ProxyURL, так и для пользовательской функции Proxy. Если вы предпочитаете не включать учетные данные в URL, установите заголовок на туннеле CONNECT самостоятельно через Transport.ProxyConnectHeader. Если ваш провайдер использует аутентификацию по белому списку IP, полностью откажитесь от учетных данных и авторизуйте IP вашего сервера в панели управления - QuantumProxies поддерживает оба варианта. Отказ в CONNECT с 407 или proxyconnect tcp почти всегда означает отсутствие или неправильные учетные данные, а не блокировку на стороне цели.

Переменная окружения HTTP_PROXY (и когда Go её игнорирует)

Go's http.DefaultTransport использует http.ProxyFromEnvironment, который читает HTTP_PROXY, HTTPS_PROXY и NO_PROXY (в соответствии с пакетом golang.org/x/net/http/httpproxy). Два поведения сбивают людей с толку. Во-первых, эти переменные принимают либо полный URL, либо голый host:port, где предполагается схема http. Во-вторых, запросы к localhost или адресу обратной петли всегда обходят прокси, возвращая nil URL. Большая ловушка - это обратное: как только вы создаете свой собственный &http.Transport{Proxy: ...}, вы заменяете поведение переменной окружения - ваша явная функция побеждает, и HTTP_PROXY игнорируется. Если вы хотите и то, и другое, оберните их самостоятельно.

SOCKS5 прокси в Go

Стандартная библиотека не имеет клиента SOCKS5, поэтому подключите golang.org/x/net/proxy и настраивайте через него. Подключите дайлер в Transport.DialContext, чтобы DNS разрешался на прокси, а не на вашем компьютере (та же проблема утечки имени хоста, которую решает socks5h в других стэках). Если вы взвешиваете две схемы, наша заметка о SOCKS5 vs HTTP прокси объясняет, когда каждая из них выигрывает:

go get golang.org/x/net/proxy
import (
  "context"
  "net"
  "net/http"
  "golang.org/x/net/proxy"
)

auth := &proxy.Auth{User: "USER", Password: "PASS"}
dialer, err := proxy.SOCKS5("tcp", "gate.quantumproxies.io:1080", auth, proxy.Direct)
if err != nil {
  panic(err)
}

transport := &http.Transport{
  DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
    return dialer.Dial(network, addr)
  },
}
client := &http.Client{Transport: transport}

Прокси в colly

Если вы скрапите с colly, вы получаете ротацию, не касаясь транспорта. Пакет colly/proxy поставляется с RoundRobinProxySwitcher, который циклически меняет конечные точки для каждого запроса, а LimitRule управляет темпом. Это самый быстрый способ создать вежливый, ротационный скрапер в Go:

import (
  "time"
  "github.com/gocolly/colly/v2"
  "github.com/gocolly/colly/v2/proxy"
)

c := colly.NewCollector(colly.Async(true))

rp, err := proxy.RoundRobinProxySwitcher(
  "http://USER:PASS@ip1.quantumproxies.io:8000",
  "http://USER:PASS@ip2.quantumproxies.io:8000",
)
if err != nil {
  panic(err)
}
c.SetProxyFunc(rp)

c.Limit(&colly.LimitRule{
  DomainGlob:  "*",
  Parallelism: 8,
  RandomDelay: 2 * time.Second,
})

Параллелизм сделан правильно (и ловушка context-canceled)

Параллелизм Go - это причина, по которой команды выбирают его для скрапинга, а также источник ошибки context canceled. Она возникает, когда контекст запроса отменяется до того, как тело полностью прочитано - обычно это истекший тайм-аут для каждого запроса или cancel(), который сработал слишком рано из-за неправильно расположенного defer. Ограничьте ваш фан-аут семафором, дайте каждому запросу свой собственный контекст тайм-аута и читайте, а затем закрывайте тело до того, как сработает отмена:

sem := make(chan struct{}, 20) // cap concurrency
var wg sync.WaitGroup

for _, u := range urls {
  wg.Add(1)
  sem <- struct{}{}
  go func(u string) {
    defer wg.Done()
    defer func() { <-sem }()

    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
    resp, err := client.Do(req)
    if err != nil {
      return // rotate/log; do not retry the same burned exit
    }
    io.Copy(io.Discard, resp.Body) // drain BEFORE cancel fires
    resp.Body.Close()
  }(u)
}
wg.Wait()

Еще две заметки для производства. Установите MaxIdleConnsPerHost достаточно высоким, чтобы большой пул переиспользовал соединения, а не истощал файловые дескрипторы - обычная причина EOF после нескольких сотен запросов. И вращайте выход при сбое, а не повторяйте попытку с тем же; мертвый резидентный IP не восстанавливается при следующей попытке. Для более широкого дизайна на миллионах страниц наше руководство по архитектуре крупномасштабного скрапинга охватывает очереди, дедупликацию и уровни прокси.

Переиспользуйте одного клиента, настройте транспорт

Создайте http.Client один раз и используйте его во всех горутинах - он безопасен для параллельного использования, и его пул соединений делает Go-скрапер быстрым. Новый клиент для каждого запроса выбрасывает keep-alive и вынуждает каждый раз выполнять новое TLS рукопожатие. Три поля Transport важны под нагрузкой: MaxIdleConns и MaxIdleConnsPerHost задают размер пула, а IdleConnTimeout завершает устаревшие соединения, чтобы ротационный пул прокси никогда не закреплял мертвый выход. Установите их явно - стандартные настройки библиотеки настроены для браузера, а не для скрапера, выполняющего тысячи запросов в минуту.

Таблица с двумя столбцами, сопоставляющая ошибки Go прокси - proxyconnect отказано, context canceled, x509, EOF, игнорируемый HTTP_PROXY - с их причинами и решениями
Почти каждая ошибка Go прокси сопоставляется с одной из этих пяти - проверьте столбец причин, прежде чем повторять попытку с тем же выходом.

Когда стоит прекратить ручную настройку транспортов

Код выше достаточен для чистых целей. Как только сайт добавляет Cloudflare, TLS отпечатки или рендеринг JavaScript, чистое TLS рукопожатие Go не похоже на Chrome, и никакой прокси не исправит это несоответствие. В этот момент Scraper API, который использует настоящий отпечаток браузера, вращает IP и рендерит JS за вас, будет менее кода и более высокой успешностью, чем поддержание стека вручную. Если вы выбираете язык для нового скрапера, наш пост о лучших прокси для веб-скрапинга описывает компромиссы между стеками.

Перенесите рендеринг и ротацию на Scraper API

Часто задаваемые вопросы

Как установить прокси для Go HTTP клиента?

Создайте http.Transport с полем Proxy и передайте его в http.Client. Используйте http.ProxyURL(u) для одного статического прокси или func(*http.Request) (*url.URL, error), чтобы выбрать выход для каждого запроса. Вставьте учетные данные в URL как http://user:pass@host:port и всегда устанавливайте Client.Timeout.

Почему Go игнорирует мою переменную HTTP_PROXY?

Потому что вы создали пользовательский Transport с явной функцией Proxy, которая заменяет поведение по умолчанию ProxyFromEnvironment. Переменные окружения применяются только когда вы используете http.DefaultTransport или устанавливаете Proxy: http.ProxyFromEnvironment самостоятельно. Также учтите, что запросы на loopback и localhost всегда обходят прокси.

Что вызывает 'context canceled' с Go прокси?

Контекст запроса был отменен до того, как тело ответа было прочитано - обычно это истекший тайм-аут для каждого запроса или cancel(), который сработал слишком рано через defer. Дайте каждому запросу свой собственный контекст тайм-аута, слейте и закройте тело до того, как выполнится отложенная отмена, и не делитесь одним отменяемым контекстом между многими горутинами.

Поддерживает ли Go SOCKS5 прокси?

Не в стандартной библиотеке, но golang.org/x/net/proxy добавляет дайлер SOCKS5. Создайте его с proxy.SOCKS5 и подключите в Transport.DialContext, чтобы имена хостов разрешались на прокси, а не утекали через локальный DNS-запрос.

Это вся картина на стороне клиента: одно поле Transport для настройки, функция для ротации, x/net/proxy для SOCKS5, colly для вежливого скрапинга и семафор плюс контексты для каждого запроса для параллелизма. Начните с чистого ротационного пула, и вы избежите большинства ошибок до их появления.

Начните с резидентных прокси QuantumProxies