Учебное пособие по Web Unlocker API: Python и Node.js, заблокированные и разблокированные сайты

Рабочий код на Python и Node.js для Web Unlocker API: один и тот же запрос к сайту, который блокирует простых клиентов, и к сайту, который этого не делает, как читать отчеты unlocker и помощник, который оплачивает unlocker только при обнаружении блока.

Web Unlocker API воспроизводит HTTP-запрос с резидентного IP под отпечатком TLS браузера, повторяя попытку с новым IP и переключаясь на реальный браузер, когда сайт отвечает вызовом. Это учебное пособие по Web Unlocker API показывает его на Python и Node.js, на сайте, который блокирует простых клиентов, и на сайте, который этого не делает.

Каждый результат, приведенный ниже, был измерен 29 сентября 2026 года: тот же URL, полученный с помощью простого curl с сервером в датацентре во Франкфурте, с помощью простого curl через резидентный прокси с выходом в США и через Web Unlocker QuantumProxies.io с выходом в США. Там, где сайт не нуждался в unlocker, мы об этом говорим, потому что самый дешевый запрос — это тот, который работает без него.

Что вам нужно перед началом

Храните оба секрета в переменных окружения. Примеры читают QP_UNLOCK_USER, QP_UNLOCK_PASSWORD и QP_API_KEY. Если пароль прокси содержит такие символы, как @ или :, закодируйте его в URL с помощью urllib.parse.quote перед добавлением в URL прокси.

Шаг 1: увидеть блокировку с помощью простых запросов Python

Начните с неудачи, чтобы знать, что вы исправляете. Цель — поиск работы на Indeed. С нашего сервера в датацентре во Франкфурте он ответил 403 страницей с заголовком 'Security Check - Indeed.com'. Через простой резидентный прокси он все равно ответил 403, на этот раз с телом, просящим клиента 'Enable JavaScript and cookies to continue'. Чистый домашний IP не помог.

import re
import requests

URL = "https://www.indeed.com/jobs?q=data+engineer&l=Austin%2C+TX"
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "en-US,en;q=0.9",
}


def title_of(html: str) -> str:
    m = re.search(r"<title[^>]*>(.*?)</title>", html, re.I | re.S)
    return m.group(1).strip() if m else ""


r = requests.get(URL, headers=HEADERS, timeout=30)
print(r.status_code, "|", title_of(r.text))
# Our datacenter run on 29 Sep 2026 saw: 403 | Security Check - Indeed.com

Наше измерение использовало curl с User-Agent Chrome, а не requests, и нет причин ожидать, что requests справится лучше. Строка User-Agent — это один заголовок. Антибот-слой также читает рукопожатие TLS и настройки HTTP/2, и они все равно указывают на Python, независимо от того, какой User-Agent вы установите. Вот почему замена заголовков редко помогает обойти обнаружение ботов в Python requests; сторона рукопожатия освещена в нашем руководстве по TLS-фингерпринтингу и почему чистые IP все равно блокируются. 403 означает, что сервер понял запрос и отказал в нем (RFC 9110, раздел 15.5.4). Это не значит, что ваш URL или параметры неверны.

Шаг 2: тот же вызов через прямой прокси

Прямой прокси — это минимальное изменение существующего кода: передайте его как прокси, и ничего больше не меняется. Целевое направление передается в имени пользователя. -country-us выбирает страну выхода, -tier-mobile переключается на мобильные выходы, а -profile-firefox (или chrome, safari, safariios, edge, brave, mobile) закрепляет один отпечаток TLS вместо того, чтобы позволять повторным попыткам его вращать.

import os
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

user = os.environ["QP_UNLOCK_USER"] + "-country-us"   # e.g. unlock-XXXXXXXX-country-us
password = os.environ["QP_UNLOCK_PASSWORD"]
PROXY = f"http://{user}:{password}@unlock.quantumproxies.io:9000"

r = requests.get(
    URL,
    headers=HEADERS,
    proxies={"http": PROXY, "https": PROXY},
    verify=False,   # or verify="qp-unlocker-ca.pem", see below
    timeout=120,
)
print(r.status_code, "|", title_of(r.text))
print("attempts:", r.headers.get("x-qp-unlocker-attempts"),
      "rendered:", r.headers.get("x-qp-unlocker-rendered"))

29 сентября это вернуло 200 с реальной страницей результатов, заголовок которой 'Now Hiring: 2,000 Data Engineer Jobs in Austin, TX | Indeed', на первой попытке TLS, за 2,6 секунды, без запуска браузера.

Почему verify=False? Чтобы переписать HTTPS-запрос, unlocker должен завершить TLS вашего клиента и представить свой собственный сертификат для целевого хоста. У вас есть два чистых варианта. Отключите проверку только для этой сессии, как выше, что делает curl -k. Или скачайте unlocker CA один раз и доверьтесь ему явно, чтобы проверка оставалась включенной:

curl -H "Authorization: Bearer $QP_API_KEY" \
  -o qp-unlocker-ca.pem https://app.quantumproxies.io/api/v1/scraper/unlock/ca

Затем передайте verify="qp-unlocker-ca.pem" вместо False. Документация по расширенному использованию requests охватывает как аргумент proxies, так и пользовательские пакеты CA. Если вы не хотите ни того, ни другого, REST-эндпоинт ниже не требует сертификата вообще.

Шаг 3: REST-эндпоинт, без прокси-протокола

REST-эндпоинт запускает тот же движок за обычным HTTPS-вызовом. Вы описываете запрос, который бы сделали (url, метод, заголовки, тело), и получаете ответ источника в формате JSON. Единственное обязательное поле — url.

import os
import requests

API_KEY = os.environ["QP_API_KEY"]

resp = requests.post(
    "https://app.quantumproxies.io/api/v1/scraper/unlock",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "url": URL,
        "country": "us",
        # "tier": "mobile",        # mobile exits, billed on the mobile balance
        # "failOnBlock": True,     # HTTP 502 instead of 200 when every tier was blocked
    },
    timeout=130,
)
resp.raise_for_status()           # 402 = no unlocker GB left, 400 = bad input
data = resp.json()["payload"]

print(data["status"], "blocked:", data["blocked"])
print("attempts:", data["attempts"], "rendered:", data["rendered"],
      "escalated:", data["escalated"], "clearance:", data.get("clearance"))
if data["blocked"]:
    print("block class:", data.get("blockClass"), "vendor:", data.get("vendor"))
html = data.get("body") or ""     # text form; bodyBase64 is always present too

Оболочка ответа — это { type, message, payload }. Внутри payload вы получаете status (код статуса источника), headers, body и bodyBase64, finalUrl, отпечаток profile, attempts, rendered, escalated, blocked и, когда что-то остановило запрос, blockClass и vendor. Объект usage сообщает о байтах, списанных за вызов.

Одно поведение, которое нужно учитывать: по умолчанию, запрос, который остался заблокированным после каждой попытки, все равно возвращается от API как HTTP 200, с blocked: true и страницей блокировки в body для инспекции. Он маркирован, а не замаскирован. Если ваша система обрабатывает любой 200 как успех, установите failOnBlock: true, и вы получите 502 с теми же диагностическими данными вместо этого. Другие поля, которые стоит знать: method и body для POST-запросов, render: "html" для запуска в браузере, waitForSelector и returnCookies.

Поток одного запроса Web Unlocker: до трех попыток TLS на разных выходных IP и отпечатках браузера, затем безголовый браузер, если появляется вызов JavaScript, затем либо страница, либо маркированный блок
Один GET через unlocker: сначала попытки TLS, реальный браузер только при появлении вызова JavaScript, и маркированный результат в любом случае.

Посмотрите, как Web Unlocker обрабатывает заблокированные запросы

Шаг 4: читайте заголовки x-qp и обрабатывайте сообщенный блок

Через прямой прокси unlocker сообщает о самом ответе. Это заголовки, которые стоит логировать при каждом вызове:

ЗаголовокЧто он сообщает
x-qp-unlocker-attemptsСколько попыток потребовалось для запроса, включая повторные попытки
x-qp-unlocker-rendered / x-qp-unlocker-escalatedУстановлен в 1, когда безголовый браузер получил страницу / когда запрос поднялся с уровня TLS
x-qp-unlocker-profileКакой отпечаток браузера использовался для возвращенной попытки
x-qp-clearanceЧто произошло с кэшем разрешений браузера при этом вызове: minted, hit или miss
x-qp-session / x-qp-countryСессия выхода и страна, фактически использованные
x-qp-unlocker-blockedПрисутствует только тогда, когда страница осталась заблокированной после каждой попытки
x-qp-block-class / x-qp-vendorКакой тип стены это был и какой антибот-вендор, если он распознан

Страница, которая все еще заблокирована, никогда не приходит как тихий 200. Если источник отказал с собственной ошибкой (403, 429, 503), этот статус передается без изменений с установленным x-qp-unlocker-blocked. Если источник предоставил страницу вызова с 2xx, прокси превращает ее в 502 и помещает код источника в x-qp-origin-status. Таким образом, одна проверка охватывает оба случая:

class Blocked(Exception):
    pass


s = requests.Session()
s.proxies = {"http": PROXY, "https": PROXY}
s.verify = False  # or "qp-unlocker-ca.pem"

NEXT_STEP = {
    "js_challenge":  "a browser could not clear it; retry later in a pinned session",
    "captcha":       "interactive captcha; decide in your own code",
    "access_denied": "the site denied access; retry with a fresh session or test tier-mobile",
    "rate_limited":  "the site is rate limiting; slow down and retry later",
    "fingerprint":   "the client fingerprint was refused; retry with another profile",
    "geo":           "the content depends on location; set the country explicitly",
    "timeout":       "the target was too slow; raise x-qp-timeout or retry",
}


def unlock_get(url: str, session_id: str | None = None) -> requests.Response:
    headers = {"x-qp-session": session_id} if session_id else {}
    r = s.get(url, headers=headers, timeout=120)
    if "x-qp-unlocker-blocked" in r.headers:
        cls = r.headers.get("x-qp-block-class", "unknown")
        raise Blocked(
            f"{url}: HTTP {r.status_code}, class={cls}, "
            f"vendor={r.headers.get('x-qp-vendor')}, next: {NEXT_STEP.get(cls, 'inspect the body')}"
        )
    return r

Шаг 5: версия на Node.js (web unlocker API на JavaScript)

В Node, fetch принимает dispatcher. Используйте ProxyAgent из undici для прямого прокси и импортируйте fetch из того же пакета, чтобы обе версии совпадали. Документация ProxyAgent перечисляет используемые здесь параметры: token для значения Proxy-Authorization и requestTls для настроек TLS по отношению к цели.

// unlock.mjs  (npm i undici)
import { readFileSync } from "node:fs";
import { fetch, ProxyAgent } from "undici";

const user = process.env.QP_UNLOCK_USER + "-country-us";
const pass = process.env.QP_UNLOCK_PASSWORD;

const dispatcher = new ProxyAgent({
  uri: "http://unlock.quantumproxies.io:9000",
  token: "Basic " + Buffer.from(user + ":" + pass).toString("base64"),
  requestTls: { rejectUnauthorized: false },
  // verified instead: requestTls: { ca: readFileSync("qp-unlocker-ca.pem") },
});

const url = "https://www.indeed.com/jobs?q=data+engineer&l=Austin%2C+TX";
const res = await fetch(url, { dispatcher, headers: { "x-qp-session": "indeed-001" } });

if (res.headers.get("x-qp-unlocker-blocked")) {
  console.error("blocked:", res.status, res.headers.get("x-qp-block-class"), res.headers.get("x-qp-vendor"));
} else {
  const html = await res.text();
  console.log(res.status, res.headers.get("x-qp-unlocker-attempts"), html.length);
}

REST-эндпоинт не требует ничего, кроме fetch, встроенного в Node 18+:

const resp = await fetch("https://app.quantumproxies.io/api/v1/scraper/unlock", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.QP_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://www.indeed.com/jobs?q=data+engineer&l=Austin%2C+TX", country: "us" }),
});
const { payload } = await resp.json();
console.log(payload.status, payload.blocked, payload.blockClass ?? "-", payload.attempts);

В axios, обычный шаблон для HTTPS-целей за прокси — это передать агент из https-proxy-agent как httpsAgent и установить proxy: false, или вызвать REST-эндпоинт выше и полностью избежать прокси-протокола.

Шаг 6: когда unlocker не нужен

Unlocker тарифицируется по ГБ за каждый байт, который он перемещает, включая повторные попытки и загрузки страниц браузера. Отправка ему страниц, которые простой запрос получает нормально, сжигает баланс впустую. В нашем запуске эти вернулись нормально без какой-либо помощи:

СайтПростой, сервер в датацентреПростой, резидентный проксиWeb Unlocker
docs.python.org (страница urllib.request)200200200, 1,6 с
books.toscrape.com200200200, 1,9 с
news.ycombinator.com200200200, 4,1 с
en.wikipedia.org (Web scraping)200200200, 1,9 с
github.com (репозиторий scrapy)200200200, 2,0 с
bbc.com (Технологии)302 redirect302 redirect200, 1,4 с

Строка BBC не является блокировкой. Наш тестовый клиент не следовал за перенаправлениями, и 302 — это сайт, отправляющий вас на другой URL. requests по умолчанию следует за перенаправлениями и попадает на страницу.

Таким образом, правильный шаблон — сначала простой запрос, unlocker при обнаружении блока. Обнаружение требует осторожности. Сканер маркеров в нашем собственном тестовом окружении отметил страницы Wikipedia и GitHub как заблокированные, потому что их тела содержат слово 'captcha', хотя обе вернули реальную страницу с 200. Проверьте код статуса, заголовок Cloudflare cf-mitigated и заголовок страницы, а не все тело:

from urllib.parse import urlsplit

BLOCK_STATUSES = {403, 429, 503}
BLOCK_TITLES = (
    "just a moment",                        # Cloudflare challenge
    "attention required",                   # Cloudflare block page
    "access denied",                        # Akamai and others
    "security check",
    "access to this page has been denied",  # PerimeterX
)
needs_unlocker: set[str] = set()            # domains that blocked plain requests


def looks_blocked(r: requests.Response) -> bool:
    if r.status_code in BLOCK_STATUSES:
        return True
    if r.headers.get("cf-mitigated") == "challenge":
        return True
    return any(t in title_of(r.text).lower() for t in BLOCK_TITLES)


def fetch(url: str, session_id: str | None = None) -> tuple[requests.Response, str]:
    host = urlsplit(url).hostname or ""
    if host not in needs_unlocker:
        try:
            r = requests.get(url, headers=HEADERS, timeout=20)
            if not looks_blocked(r):
                return r, "plain"
        except requests.RequestException:
            pass  # a reset or a timeout counts as a block too
        needs_unlocker.add(host)
    return unlock_get(url, session_id), "unlocker"   # from step 4; raises Blocked


for u in ("https://docs.python.org/3/library/urllib.request.html", URL):
    resp, route = fetch(u)
    print(route, resp.status_code, title_of(resp.text)[:60])

Ветка except имеет значение. Категория ноутбуков Best Buy никогда не давала нашим простым клиентам HTTP-ответа вообще: соединение не удалось как с сервера в датацентре, так и через резидентный прокси. Через unlocker тот же URL вернулся 200 с первой попытки за 6,3 секунды. Скрапер, который проверяет только коды статуса, никогда бы не эскалировал это.

Измеренные результаты: четыре сайта, которые нуждались в unlocker

Измерено 29 сентября 2026 года. Простые запросы были curl с User-Agent Chrome, один раз с сервера в датацентре во Франкфурте и один раз через резидентный прокси с выходом в США. Запросы Web Unlocker использовали резидентный уровень с выходом в США. Время указано по часам для одного запроса.

СайтПростой, сервер в датацентреПростой, резидентный проксиWeb Unlocker
Indeed (поиск работы)403, страница 'Security Check'403, 'Enable JavaScript and cookies to continue'200, 1 попытка, 2,6 с
Glassdoor (обзоры компаний)403, страница 'Security' с капчей403, 'Enable JavaScript and cookies to continue'200, 2 попытки + браузер, 17,2 с
Walmart (поиск)307 redirect, нет страницы результатов307 redirect, нет страницы результатов200, 1 попытка, 4,8 с
Best Buy (категория ноутбуков)Соединение не удалось, нет ответаСоединение не удалось, нет ответа200, 1 попытка, 6,3 с

Вкратце: Indeed, Walmart и Best Buy прошли с первой попытки TLS без браузера. Glassdoor потребовал полный путь, две попытки TLS и затем безголовый браузер, чтобы ответить на вызов JavaScript, поэтому это заняло 17,2 секунды. Для Walmart мы не записали, куда указывал 307. Что мы можем сказать, так это то, что ни один из простых клиентов не получил страницу результатов, а unlocker получил.

Повторные запросы к сайту, защищенному вызовами, становятся быстрее, когда вы закрепляете сессию. Отправьте то же значение заголовка x-qp-session, или сохраните тот же -session-<id> в имени пользователя прокси, и cookie разрешения, полученное браузером, будет повторно использовано. В тесте Glassdoor 29 сентября 2026 года, с закрепленной сессией в имени пользователя и двумя отдельными соединениями, первый запрос занял 14,0 секунды через браузер, а второй 1,4 секунды, с x-qp-clearance: hit. Наше руководство по скрапингу объявлений о работе Indeed охватывает, что делать с страницей, как только вы ее получите.

Диаграмма принятия решений для скрапера: сначала попробуйте простой запрос и отправьте запрос через web unlocker только тогда, когда статус, заголовок cf-mitigated или заголовок страницы указывают на блокировку
Сначала простой запрос, unlocker при обнаружении блока. Unlocker тарифицирует каждый байт, который он перемещает, поэтому самый дешевый запрос — это тот, который работает без него.

Если вы пришли сюда из-за 403, который происходит только за пределами браузера, наше объяснение на почему curl получает 403, когда браузер работает углубляется в диагностику. Для анализа по сайтам, какой уровень вам действительно нужен, см. web unlocker против резидентного прокси, тестирование.

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

Существует ли бесплатный Web Unlocker API?

Открытые компоненты бесплатны: requests, curl_cffi для TLS, похожего на браузер, и Playwright для реального браузера. Их правильная работа — это работа, потому что вам также нужны чистые выходные IP и браузер для вызовов JavaScript. На QuantumProxies.io создание аккаунта бесплатно, а трафик unlocker тарифицируется по ГБ с предоплаченного баланса, который вы пополняете в панели управления.

Можно ли скрапить Glassdoor или Zillow с помощью Python?

В нашем тесте 29 сентября 2026 года простые запросы к странице обзоров Glassdoor получили 403 как с сервера в датацентре, так и через резидентный прокси, а unlocker вернул страницу после эскалации браузера. Zillow заблокировал сервер в датацентре, но предоставил страницу через простой резидентный прокси. Проверьте условия каждого сайта перед сбором чего-либо.

Может ли веб-скрапинг быть обнаружен?

Да. Сайты оценивают тип и историю сети IP, рукопожатие TLS, настройки HTTP/2, порядок заголовков и, на страницах вызовов, что JavaScript сообщает о браузере. Простой запрос Python проваливает несколько из этих проверок одновременно. Вот почему изменение только User-Agent редко помогает, и почему unlocker меняет IP, отпечаток и, при необходимости, весь клиент.

Нужно ли устанавливать сертификат для использования прямого прокси?

Только для HTTPS через классический прямой прокси, потому что unlocker завершает TLS для переписывания запроса. Либо пропустите проверку для этой сессии (verify=False в Python, -k в curl), либо скачайте CA с /api/v1/scraper/unlock/ca с вашим API-ключом и доверьтесь ему. REST-эндпоинт не требует сертификата вообще.

Как сохранить тот же IP для нескольких запросов?

Отправьте то же значение заголовка x-qp-session на каждый запрос через прямой прокси, или поместите тот же -session-<id> в имя пользователя прокси. Вызовы, разделяющие эту сессию, выходят с того же выхода, и cookie разрешения, полученное браузером, повторно используется. На REST-эндпоинте передайте sessionId с rotation, установленным в sticky. Sticky-сессия может длиться от 3 до 1 440 минут.

Весь шаблон умещается в одно предложение: получайте страницы просто, обнаруживайте блокировку по статусу, заголовку и заголовку страницы, отправляйте только эти URL через unlocker и логируйте заголовки x-qp, чтобы знать, сколько стоила каждая страница. Начните с резидентного уровня и держите мобильный уровень для целей, которые также отказываются от резидентных диапазонов.

Попробуйте Web Unlocker на резидентных выходах