СправочникСбор данных › Прокси в Python и curl

Прокси в Python и curl: requests, httpx, Scrapy, Selenium и разбор ошибок настройки

Схема подключения прокси в коде на Python и в curl с разбором по инструментам

Прокси в коде это промежуточный сервер, адрес которого клиент подставляет перед обращением к целевому хосту: запрос уходит на прокси, прокси открывает соединение до сайта и возвращает ответ обратно клиенту. Целевой сервер видит выходной адрес прокси. В Python подключение задаётся полем клиента или словарём, в curl флагом командной строки или переменной окружения.

Дальше я разбираю пять инструментов по отдельности: requests, httpx, Scrapy, Selenium и curl. У каждого свой способ принять адрес и свой набор мест, где настройка ломается тихо, без сообщения об ошибке. Отдельно разбираю три вопроса, которые повторяются во всех пяти: авторизация по логину против привязки к адресу машины, схема socks5 против socks5h, время ожидания вместе с повторами.

Четыре ошибки настройки прокси, которые повторяются в коде чаще всего

Из чего состоит строка подключения

Строка одинаковая почти везде: схема, доступ, хост, порт. Выглядит она так: socks5h://login:password@203.0.113.10:1080. Схема говорит клиенту, по какому протоколу разговаривать с прокси. Часть до собаки это доступ по логину, её можно опустить, если сервер узнаёт вас по адресу машины. Хост и порт берутся из кабинета поставщика.

Схема прокси и схема целевого сайта живут отдельно. Строка http://login:password@203.0.113.10:8080 спокойно обслуживает запрос к адресу на https: клиент отправляет прокси-серверу метод CONNECT, тот поднимает туннель, и дальше шифрование идёт между вашим клиентом и сайтом напрямую. Я видел, как люди пишут в поле для https строку с https:// и получают ошибку рукопожатия, потому что сам прокси на этом порту TLS не ждёт. Схема в строке описывает канал до прокси.

ИнструментГде задаётся адресЧто принимает
requestsсловарь proxies у запроса или у сессииключи http и https
httpxпараметр proxy у Client и AsyncClientодна строка или карта по схемам
Scrapyrequest.meta['proxy'] или своё middlewareодна строка на запрос
Seleniumопции драйвера или расширение профиляхост и порт, доступ по адресу машины
curlфлаг -x либо переменные окружениясхема, доступ, хост, порт

Первое действие после сборки строки у меня всегда одинаковое: спросить у сервиса эха, какой адрес он видит на той стороне. Пока ответ не показал выходной адрес прокси, конфиг считается непроверенным, сколько бы раз его ни перечитывали глазами. Половина разборов на тему «прокси не работает» заканчивается тем, что запросы всё это время уходили напрямую, и код 200 в логе спокойно это скрывал.

print(requests.get("https://api.ipify.org", proxies=proxies, timeout=(4, 10)).text)
print(requests.get("https://api.ipify.org", timeout=(4, 10)).text)

Две строки подряд, два разных адреса в выводе. Совпали значит трафик идёт в обход прокси, и дальше смотреть надо в конфиг.

Порт тоже несёт смысл. HTTP-порт и SOCKS-порт у одного и того же адреса разные, и поставщик выдаёт оба. Я держу в конфиге два поля и подставляю нужное по схеме, потому что перепутанный порт даёт обрыв соединения без внятного текста. Если прогон идёт по спискам URL и вам нужны просто выходные адреса с предсказуемым откликом, я беру приватные адреса для парсинга и раскладываю их по потокам заранее, ещё до старта.

requests: одна сессия на весь список URL

Самая частая настройка в Python выглядит на три строки, и работает она правильно:

import requests

proxies = {
    "http":  "http://login:password@203.0.113.10:8080",
    "https": "http://login:password@203.0.113.10:8080",
}
r = requests.get("https://example.com/page", proxies=proxies, timeout=(4, 25))
print(r.status_code, r.elapsed.total_seconds())

Обратите внимание на значение у ключа https: там стоит схема http. Это правильно. Ключ означает протокол целевого сайта, значение описывает канал до прокси.

Проблема появляется на объёме. Вызов requests.get создаёт новую сессию, новый пул соединений и новое рукопожатие TLS на каждый URL. На 3000 адресов это 3000 полных установок соединения. Я замерял на своём прогоне: с сессией общее время упало с 41 минуты до 26 при том же числе потоков. Держите сессию.

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

s = requests.Session()
s.proxies.update(proxies)
s.headers.update({"Accept-Language": "ru,en;q=0.8"})

retry = Retry(total=3, backoff_factor=1.5,
              status_forcelist=[429, 502, 503, 504],
              allowed_methods=["GET", "HEAD"])
adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20)
s.mount("http://", adapter)
s.mount("https://", adapter)

r = s.get("https://example.com/page", timeout=(4, 25))

Два поля тут стоят отдельного внимания. pool_maxsize по умолчанию равен 10, и при 20 потоках половина из них будет ждать свободного слота в пуле, а вы будете думать, что тормозит прокси. Я ставлю размер пула равным числу потоков. backoff_factor задаёт паузу перед повтором: 1.5 даёт задержки примерно 0, 3 и 6 секунд. Без паузы повтор уходит в тот же рейт-лимит и получает тот же отказ.

Сессия при этом не запирает вас на одном выходе. Аргумент proxies у отдельного запроса перекрывает то, что записано в сессии, и это удобно, когда основная масса URL идёт через общий пул, а десяток тяжёлых страниц вы уводите на отдельный адрес:

r = s.get(url, proxies={"http": alt, "https": alt}, timeout=(4, 25))

Перекрытие живёт ровно один вызов. Следующий s.get снова возьмёт адрес из сессии, и это ловит тех, кто ждёт, что смена запомнится.

Ещё одна тихая деталь: переменные окружения. Если в системе выставлен http_proxy, requests подхватит его сам, и вы получите не тот выход, который прописали в коде. Проверить просто: s.trust_env = False отключает чтение окружения полностью. Тот же флаг снимает чтение ~/.netrc и системных сертификатов, поэтому на серверах с самоподписанным корневым сертификатом я выключаю доверие к окружению и передаю путь к сертификату руками через s.verify.

httpx: асинхронный клиент и один пул на весь прогон

httpx принимает прокси одной строкой, синтаксис проще, чем словарь:

import asyncio, httpx

async def main(urls):
    limits = httpx.Limits(max_connections=20, max_keepalive_connections=20)
    timeout = httpx.Timeout(connect=4.0, read=25.0, write=10.0, pool=5.0)
    async with httpx.AsyncClient(
        proxy="socks5h://login:password@203.0.113.10:1080",
        limits=limits, timeout=timeout, follow_redirects=True
    ) as client:
        sem = asyncio.Semaphore(20)
        async def one(u):
            async with sem:
                try:
                    resp = await client.get(u)
                    return u, resp.status_code
                except httpx.TimeoutException:
                    return u, "timeout"
        return await asyncio.gather(*[one(u) for u in urls])

asyncio.run(main(["https://example.com/a", "https://example.com/b"]))

Для схемы socks5 нужен дополнительный пакет: pip install "httpx[socks]". Без него клиент поднимет ошибку про неизвестную схему, и текст ошибки на первый взгляд выглядит как опечатка в строке.

Главная ошибка с httpx повторяет ошибку с requests, только цена выше. Люди создают AsyncClient внутри корутины, которая обрабатывает один URL. Тысяча URL превращается в тысячу клиентов, каждый со своим пулом, и все они одновременно держат сокеты. Я так уронил свой же прогон: файловые дескрипторы у процесса закончились на седьмой минуте. Клиент создаётся один раз на весь прогон и передаётся внутрь.

Второе место, где путаются: max_connections в лимитах и семафор делают разные вещи. Лимит ограничивает сокеты в пуле, семафор ограничивает количество корутин, которые одновременно зашли в запрос. Если семафор выставлен на 200, а пул на 20, то 180 корутин будут стоять в очереди за сокетом и съедать таймаут пула. Я держу оба числа равными.

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

Scrapy: middleware и раздача адресов по запросам

Scrapy читает прокси из request.meta['proxy']. Одиночный адрес можно поставить прямо в паук, но полезнее написать middleware, которое раздаёт адреса из пула и умеет отложить сломанный. Код кладётся в файл middlewares.py проекта:

import random, logging

class PoolProxyMiddleware:
    def __init__(self, pool):
        self.pool = list(pool)
        self.bad = {}
        self.log = logging.getLogger(__name__)

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings.getlist("PROXY_POOL"))

    def _pick(self):
        alive = [p for p in self.pool if self.bad.get(p, 0) < 3]
        return random.choice(alive or self.pool)

    def process_request(self, request, spider):
        if "proxy" not in request.meta:
            request.meta["proxy"] = self._pick()

    def process_response(self, request, response, spider):
        if response.status in (403, 429, 407):
            p = request.meta.get("proxy")
            self.bad[p] = self.bad.get(p, 0) + 1
            new = self._pick()
            self.log.info("смена выхода %s -> %s", p, new)
            request.meta["proxy"] = new
            request.dont_filter = True
            return request
        return response

    def process_exception(self, request, exception, spider):
        p = request.meta.get("proxy")
        self.bad[p] = self.bad.get(p, 0) + 3
        request.meta["proxy"] = self._pick()
        request.dont_filter = True
        return request

Подключается в настройках проекта:

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.PoolProxyMiddleware": 350,
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 750,
}
PROXY_POOL = [
    "http://login:password@203.0.113.10:8080",
    "http://login:password@203.0.113.11:8080",
]
CONCURRENT_REQUESTS = 20
CONCURRENT_REQUESTS_PER_DOMAIN = 8
DOWNLOAD_TIMEOUT = 25
RETRY_TIMES = 3
RETRY_HTTP_CODES = [429, 500, 502, 503, 504, 522, 524]

Порядок чисел в словаре middleware важнее, чем кажется. Своё middleware должно отработать до штатного HttpProxyMiddleware, поэтому его вес меньше: 350 против 750. Я один раз поставил 800 и полчаса смотрел, почему поле proxy в мете есть, а запросы идут с адреса машины. Штатное middleware к тому моменту уже собрало запрос.

Возврат request из process_response кладёт запрос обратно в очередь. Флаг dont_filter тут обязателен, иначе дедупликатор выбросит повтор как уже виденный URL, и вы получите прогон с дырами. Счётчик self.bad отводит адрес после трёх отказов подряд.

Отдельная деталь про Scrapy и schemes. Для схемы socks5 штатное middleware не подходит, нужен внешний загрузчик вроде scrapy-socks. Я в проектах на Scrapy остаюсь на HTTP-схеме: HTTP-адреса под прогоны по спискам заводятся в пул одной строкой и не требуют дополнительных зависимостей.

Selenium: авторизация, которую драйвер не принимает

Тут самая известная ловушка во всём списке. Chrome через ключ --proxy-server принимает хост и порт, доступ по логину он игнорирует:

from selenium import webdriver

opts = webdriver.ChromeOptions()
opts.add_argument("--proxy-server=http://203.0.113.10:8080")
driver = webdriver.Chrome(options=opts)
driver.get("https://example.com")

Строка вида --proxy-server=http://login:password@203.0.113.10:8080 до сервера доедет, а логин с паролем по дороге отвалятся. Браузер поднимет окно системного диалога с запросом доступа, скрипт встанет и будет стоять до таймаута. В headless-режиме диалог невидим, и внешне это похоже на зависший драйвер.

Рабочих путей два, и я пользуюсь обоими в зависимости от задачи.

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

Второй путь нужен, когда машина ходит из-под меняющегося адреса и привязать её не выйдет. Тогда доступ подставляет расширение, собранное на лету:

import zipfile, os

MANIFEST = """
{ "version":"1.0.0","manifest_version":2,"name":"px",
  "permissions":["proxy","tabs","<all_urls>","webRequest","webRequestBlocking"],
  "background":{"scripts":["bg.js"]} }
"""

BG = """
var cfg = { mode:"fixed_servers", rules:{ singleProxy:{
  scheme:"http", host:"%s", port:parseInt(%s) }, bypassList:["localhost"] } };
chrome.proxy.settings.set({value:cfg, scope:"regular"}, function(){});
chrome.webRequest.onAuthRequired.addListener(
  function(d){ return { authCredentials:{ username:"%s", password:"%s" } }; },
  { urls:["<all_urls>"] }, ["blocking"]);
"""

def make_ext(path, host, port, user, pwd):
    with zipfile.ZipFile(path, "w") as z:
        z.writestr("manifest.json", MANIFEST)
        z.writestr("bg.js", BG % (host, port, user, pwd))
    return path

opts = webdriver.ChromeOptions()
opts.add_extension(make_ext("px.zip", "203.0.113.10", "8080", "login", "password"))

Расширение перехватывает событие onAuthRequired и отдаёт доступ само, диалог не появляется. Ограничение известное: в headless старого образца расширения не грузятся, нужен новый headless-режим либо запуск с виртуальным дисплеем.

Про Firefox короче. Он умеет прокси через профиль и переменные network.proxy.*, доступ по логину там тоже требует отдельной обработки, я обычно ставлю расширение или включаю привязку по адресу.

curl: флаг, переменные окружения и файл настроек

curl удобен для проверки, что адрес живой, ещё до запуска кода. Минимальная команда:

curl -x http://login:password@203.0.113.10:8080 \
     --connect-timeout 4 --max-time 25 \
     -s -o /dev/null -w "%{http_code} %{time_total}\n" \
     https://api.ipify.org

Для SOCKS отдельный флаг или схема в строке:

curl --socks5-hostname 203.0.113.10:1080 -U login:password https://api.ipify.org
curl -x socks5h://login:password@203.0.113.10:1080 https://api.ipify.org

Обе строки делают одно и то же. Флаг --socks5-hostname и схема socks5h означают одинаковое поведение при разборе домена, ниже я объясняю разницу подробно.

Переменные окружения curl читает сам, и это удобно на сервере, где через прокси должны ходить сразу несколько утилит:

export http_proxy="http://login:password@203.0.113.10:8080"
export https_proxy="$http_proxy"
export no_proxy="localhost,127.0.0.1,.internal"

Имена переменных в нижнем регистре читают curl, wget, pip, git и библиотеки Python. Верхний регистр HTTPS_PROXY тоже поддерживается, а вот HTTP_PROXY в верхнем регистре curl намеренно игнорирует из-за старой проблемы с заголовком Proxy в CGI-окружении. Пишите строчными буквами, так надёжнее.

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

Постоянные настройки удобно держать в файле ~/.curlrc:

proxy = "http://203.0.113.10:8080"
connect-timeout = 4
max-time = 25
retry = 2
retry-delay = 3

Файл читается автоматически при каждом вызове curl. Отключается флагом -q, если нужен разовый вызов напрямую.

Когда адрес ведёт себя странно, я включаю подробный вывод и читаю первые строки:

curl -v -x http://login:password@203.0.113.10:8080 https://example.com 2>&1 | head -20

Смотреть тут нужно на три места. Строка Connected to 203.0.113.10 подтверждает, что до прокси мы дошли. Строка CONNECT example.com:443 показывает, что клиент попросил туннель, и рядом с ней стоит ответ прокси: 200 Connection established означает согласие, 407 означает отказ по доступу, 403 означает, что прокси запретил сам хост. Дальше идёт рукопожатие TLS уже с целевым сайтом, и если ошибка вылезает там, прокси к ней отношения не имеет. Такой разбор в три строки экономит мне куда больше времени, чем перебор параметров наугад.

socks5 против socks5h: куда уходит резолв домена

Разница в одной букве, и она меняет поведение сети целиком.

Куда уходит резолв домена при схеме socks5 и при схеме socks5h

При схеме socks5 доменное имя разбирает ваша машина. Клиент спрашивает системный резолвер, получает IP-адрес и передаёт прокси-серверу уже готовый адрес. Локальный DNS видит каждый хост, к которому вы обращаетесь, и отвечает адресом ближайшего к вам узла CDN.

При схеме socks5h имя хоста уходит на прокси в исходном виде, и разбирает его прокси-сервер своим резолвером. Локальная машина о домене остаётся в неведении. CDN при этом отдаёт узел, ближайший к выходному адресу.

Три практических следствия я вижу постоянно:

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

Второе. Внутренние домены заказчика, которые видны только из его сети, при socks5 не разберутся вообще. Ваш резолвер про них не знает. Схема socks5h отдаёт имя на ту сторону, и там оно разбирается нормально.

Третье. Утечка имён хостов. При socks5 ваш провайдер видит список доменов в DNS-запросах, даже когда сам трафик идёт через прокси. Схема socks5h этот список закрывает.

Что происходитsocks5socks5h
Кто разбирает доменлокальный резолверпрокси-сервер
Что уходит на проксиготовый IPимя хоста
Узел CDN в ответеближайший к вамближайший к выходу
Внутренние домены сети выходанедоступныдоступны
Видимость доменов у провайдераполнаязакрыта

В httpx и requests схема пишется прямо в строке подключения. В curl схеме socks5h соответствует флаг --socks5-hostname, а простому socks5 соответствует --socks5. Я по умолчанию беру socks5h везде и отступаю от этого, только когда прокси-сервер сам не умеет разбирать имена. Для такой работы удобны адреса с поддержкой SOCKS5: протокол несёт и TCP, и UDP, и передаёт имя хоста на сторону выхода без дополнительных обёрток.

Авторизация по логину и авторизация по адресу машины

Два способа доступа отличаются местом хранения секрета.

Доступ по логину живёт в строке подключения. Он переносится вместе с кодом, работает с любой машины и легко ломается о спецсимволы: пароль с символом собаки или двоеточием разрывает строку, и клиент читает её неправильно. Лечится кодированием:

from urllib.parse import quote
user, pwd = "login", "p@ss:word/1"
url = f"http://{quote(user, safe='')}:{quote(pwd, safe='')}@203.0.113.10:8080"

Доступ по адресу машины живёт в кабинете поставщика: вы добавляете внешний IP сервера в список разрешённых, и строка подключения теряет часть с секретом. Selenium начинает работать без расширения, логи перестают содержать пароль, а сам пароль не уезжает в репозиторий вместе с конфигом.

Разница видна и в отказах. Код 407 приходит при доступе по логину и означает, что прокси не принял пару логин и пароль. При привязке по адресу приходит обрыв соединения на этапе подключения: сервер молчит в ответ незнакомому адресу. Я это различаю по тексту исключения, и оно сразу говорит, куда смотреть.

Мой рабочий порядок такой. На постоянных серверах я включаю привязку по адресу. На ноутбуке и в контейнерах, которые поднимаются с меняющимся выходом, оставляю доступ по логину и держу пару в переменных окружения, чтобы она не попадала в файлы проекта:

import os
url = f"http://{os.environ['PX_USER']}:{os.environ['PX_PASS']}@{os.environ['PX_HOST']}"

Таймауты, повторы и разбор отказов на прогоне

Одно число в поле таймаута это частая настройка, и она даёт странное поведение. Клиент в Python различает время на установку соединения и время на чтение ответа. Соединение до живого прокси поднимается за доли секунды, а ответ от медленного сайта может идти двадцать секунд законно. Единый таймаут в 30 секунд означает, что мёртвый адрес будет держать поток полминуты. Единый таймаут в 5 секунд рубит нормальные медленные страницы.

Я развожу эти два числа: 4 секунды на связь, 25 на чтение. Мёртвый адрес отваливается за 4 секунды и уходит в счётчик отказов, живая медленная страница дочитывается.

ИнструментВремя на связьВремя на чтениеПовторы
requestsпервый элемент timeout=(4, 25)второй элемент кортежаRetry в HTTPAdapter
httpxTimeout(connect=4.0)Timeout(read=25.0)HTTPTransport(retries=2)
ScrapyDOWNLOAD_TIMEOUT общий на запростот же параметрRETRY_TIMES, RETRY_HTTP_CODES
Seleniumset_page_load_timeout(30)set_script_timeout(20)свой цикл вокруг get
curl--connect-timeout 4--max-time 25--retry 2 --retry-delay 3

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

Повторять полезно тоже с разбором. Коды 429, 502, 503 и 504 говорят о временном состоянии на той стороне, их я гоняю по три раза. Код 403 обычно держится за выходной адрес, и повтор с того же выхода даёт тот же ответ, поэтому в счётчик повторов он у меня не идёт вовсе: запрос сразу уходит на другой адрес пула. Код 407 повторять бессмысленно до тех пор, пока не поправлен доступ, он попадает в отдельный список и останавливает прогон, если таких набирается больше десятка подряд.

Отличить медленный прокси от медленного сайта помогает разбивка времени. curl отдаёт её готовой, и я снимаю замер на пробном запросе перед большим прогоном:

curl -x http://203.0.113.10:8080 -s -o /dev/null \
     -w "connect=%{time_connect} appconnect=%{time_appconnect} ttfb=%{time_starttransfer}\n" \
     https://example.com

Поле connect показывает время до прокси, appconnect добавляет рукопожатие TLS, ttfb даёт момент прихода первого байта от сайта. Большой connect при малом ttfb указывает на прокси. Малый connect при большом ttfb указывает на сайт. Я держу порог 0,4 секунды на связь: адрес, который стабильно выходит за него, отправляется в конец очереди пула.

Причины отказов на прогоне 3000 адресов в 20 потоков

Свежий прогон для примера: 3000 адресов, 20 потоков, один пул выходов, схема socks5h. Отказов вышло 263 штуки. Таймаут чтения дал 128, отказ авторизации 407 дал 74, разрыв соединения 39, ошибка резолва домена 22. Повтор с паузой вернул 218 запросов из 263, оставшиеся 45 я отложил в отдельный список и прогнал позже с другим пулом.

Разбор по причинам сразу подсказывает, что чинить. Семь десятков отказов 407 при доступе по логину означали, что часть потоков брала строку из окружения, где пароль устарел. Двадцать два отказа резолва пришлись на домены, которые прокси-сервер разбирал через свой DNS с задержкой; я поднял таймаут связи с 3 секунд до 4, и они ушли.

Ошибки логируйте с адресом выхода в строке. Без этого поля вы видите набор таймаутов и никак не поймёте, что 90 процентов из них дал один нездоровый адрес:

self.log.warning("fail url=%s proxy=%s reason=%s", url, proxy, type(exc).__name__)

Как я раскладываю пул между инструментами

Пять инструментов из статьи нагружают сеть по-разному, и делить пул поровну смысла нет.

requests и httpx держат много коротких соединений. Здесь один адрес спокойно тянет 8 потоков по одному домену, если сайт не считает частоту жёстко. Scrapy на большом проекте открывает соединения десятками, и я даю ему собственный сегмент пула, чтобы очередь одного паука не мешала другому. Selenium расходует адрес медленнее всех: браузер держит сессию долго, тянет картинки и скрипты, поэтому на профиль браузера я выделяю отдельный выход и не отдаю его коду.

curl остаётся служебным. Через него я проверяю адрес перед тем, как класть его в пул, и снимаю замер отклика:

for ip in $(cat pool.txt); do
  code=$(curl -s -o /dev/null -w "%{http_code} %{time_connect}" \
         -x "http://login:password@$ip:8080" --connect-timeout 4 \
         https://api.ipify.org)
  echo "$ip $code"
done

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

Соседние материалы раздела продолжают начатое здесь. Смену выхода по интервалу и по счётчику запросов разбирает заметка ротация адресов при сборе данных, она объясняет, когда менять выход стороной сервера и когда своим кодом. Что делать с проверочными окнами, которые приходят в ответ на месте страницы, написано в материале капча при сборе данных. Те же строки подключения в визуальных сценариях описаны в статье прокси в n8n и Make. Из раздела инструментов сюда ближе всего настройка A-Parser с прокси, где логика пула и повторов разложена под готовый парсер без единой строки кода.