← بازگشت به وبلاگ

پروکسی ایجنت Undici در Node.js: تنظیم پروکسی HTTP/SOCKS5 و چرخش IP بدون وابستگی‌های اضافی

نحوه استفاده از ProxyAgent داخلی undici در Node.js برای کار با پروکسی‌های HTTP و SOCKS5، سازماندهی چرخش IP و بدون نیاز به https-proxy-agent و socks-proxy-agent را بررسی می‌کنیم.

📅۲۶ مهر ۱۴۰۵

بیشتر راهنماهای پروکسی در Node.js توصیه می‌کنند که https-proxy-agent، socks-proxy-agent و چند بسته دیگر را به پروژه اضافه کنید. در واقع، از نسخه Node.js 18+، کلاینت HTTP داخلی undici به طور پیش‌فرض شامل ProxyAgent است که بیشتر وظایف تجزیه و آزمایش API از طریق پروکسی را پوشش می‌دهد. در این مقاله بررسی خواهیم کرد که چگونه تونل‌سازی HTTP/HTTPS را از طریق undici تنظیم کنیم، چرخش IP را بین درخواست‌ها سازماندهی کنیم و چه کاری باید انجام دهیم اگر به SOCKS5 نیاز داریم و نمی‌خواهیم وابستگی اضافی را اضافه کنیم.

چرا undici به جای axios و request

undici یک کلاینت HTTP/1.1 و HTTP/2 است که توسط تیم Node.js نوشته شده و از نسخه 18 به بعد در هسته fetch جهانی قرار دارد. این کلاینت هیچ وابستگی انتقالی ندارد، در بیشتر بنچمارک‌ها از axios سریع‌تر است و نکته مهم برای موضوع ما این است که یک ماژول بومی برای کار با پروکسی از طریق تونل‌سازی HTTP CONNECT دارد.

هنگامی که شما Wildberries، Ozon را تجزیه می‌کنید یا خلاقیت‌های تبلیغاتی را از مناطق جغرافیایی مختلف آزمایش می‌کنید، هر بسته اضافی در node_modules یک نقطه شکست بالقوه در هنگام به‌روزرسانی Node.js است. undici همراه با زمان اجرا ارائه می‌شود، بنابراین شما پیاده‌سازی به‌روز TLS، HTTP/2 و keep-alive را بدون ریسک اینکه یک کتابخانه رها شده در نسخه جدید دچار مشکل شود، دریافت می‌کنید.

نصب آن به طرز شگفت‌انگیزی ساده است:

npm install undici

اگر شما Node.js 20+ دارید، بخشی از API به طور جهانی بدون وارد کردن در دسترس است، اما برای ProxyAgent وارد کردن آن هنوز لازم است زیرا به طور پیش‌فرض در fetch جهانی صادر نمی‌شود.

تنظیمات پایه ProxyAgent

یک مثال کارکردی حداقلی از درخواست از طریق پروکسی به شکل زیر است:

import { ProxyAgent, fetch } from 'undici';

const proxyAgent = new ProxyAgent('http://proxy.example.com:8080');

const response = await fetch('https://httpbin.org/ip', {
  dispatcher: proxyAgent,
});

const data = await response.json();
console.log(data);

نکته کلیدی — پارامتر dispatcher است. این گزینه agent نیست، مانند ماژول قدیمی http، بلکه دقیقاً یک دیسپچر undici است که اتصال را قبل از دست دادن TLS می‌گیرد و تونل را از طریق متد CONNECT به میزبان هدف برقرار می‌کند. برای درخواست‌های HTTPS از طریق پروکسی این موضوع حیاتی است: پروکسی HTTP معمولی محتوای ترافیک رمزگذاری شده را نمی‌بیند، فقط بایت‌ها را درون تونل TCP منتقل می‌کند.

اگر شما با fetch کار نمی‌کنید، بلکه با undici.request سطح پایین کار می‌کنید، سینتکس مشابه است:

import { request, ProxyAgent } from 'undici';

const proxyAgent = new ProxyAgent('http://proxy.example.com:8080');

const { statusCode, body } = await request('https://api.example.com/data', {
  dispatcher: proxyAgent,
});

console.log(statusCode);
for await (const chunk of body) {
  console.log(chunk.toString());
}

برای تجزیه‌کننده‌های تولیدی که صدها درخواست در دقیقه انجام می‌دهند، مهم است که یک ProxyAgent جدید برای هر درخواست ایجاد نکنید — این کار باعث از بین رفتن connection pooling می‌شود. یک بار یک نمونه از ایجنت را برای استخر اتصالات ایجاد کنید و آن را بین فراخوانی‌ها دوباره استفاده کنید.

احراز هویت پروکسی با نام کاربری و رمز عبور

بیشتر ارائه‌دهندگان پروکسی پولی دسترسی را با یک جفت نام کاربری/رمز عبور ارائه می‌دهند، نه با لیست سفید IP. در undici، اعتبارنامه‌ها مستقیماً در URL پروکسی منتقل می‌شوند:

import { ProxyAgent, fetch } from 'undici';

const login = 'user123';
const password = 'pass456';
const proxyUrl = `http://${login}:${password}@gate.proxycove.com:9000`;

const proxyAgent = new ProxyAgent(proxyUrl);

const response = await fetch('https://httpbin.org/ip', {
  dispatcher: proxyAgent,
});

console.log(await response.json());

توجه داشته باشید: اگر نام کاربری یا رمز عبور شامل کاراکترهای خاص (@، :، /) باشد، باید از encodeURIComponent استفاده کنید، در غیر این صورت تجزیه‌کننده URL درون undici رشته اتصال را به درستی تجزیه نمی‌کند و شما به جای پیام واضح، خطای احراز هویت 407 دریافت خواهید کرد.

برای کارهایی که ثبات جلسه مهم است — به عنوان مثال، احراز هویت در بازار با وابستگی به یک IP در طول جلسه — بهتر است از پروکسی‌های مسکونی با جلسه چسبنده استفاده کنید: IP برای مدت معین تغییر نمی‌کند، به این معنی که سرویس «پرش‌ها» بین مناطق جغرافیایی را نمی‌بیند و در هر درخواست نیاز به کد کپچا مجدد ندارد.

چرخش پروکسی بین درخواست‌ها

اگر شما یک لیست از N پروکسی دارید و هدف توزیع درخواست‌ها بین آنها است، ساده‌ترین گزینه — round-robin بدون کتابخانه‌های خارجی برای صف‌ها:

import { ProxyAgent, fetch } from 'undici';

const proxyList = [
  'http://user:[email protected]:8000',
  'http://user:[email protected]:8000',
  'http://user:[email protected]:8000',
];

let currentIndex = 0;

function getNextAgent() {
  const proxyUrl = proxyList[currentIndex];
  currentIndex = (currentIndex + 1) % proxyList.length;
  return new ProxyAgent(proxyUrl);
}

async function fetchWithRotation(url) {
  const agent = getNextAgent();
  return fetch(url, { dispatcher: agent });
}

for (let i = 0; i < 10; i++) {
  const res = await fetchWithRotation('https://httpbin.org/ip');
  console.log(await res.json());
}

این رویکرد برای تجزیه کارت‌های محصولات یا نظارت بر قیمت‌ها خوب عمل می‌کند، زمانی که هر درخواست بعدی منطقی مستقل از درخواست قبلی است. برای کارهایی که نیاز به سرعت بالای تغییر IP در هر درخواست بدون مدیریت دستی لیست دارند، بهتر است از پروکسی با چرخش خودکار در سمت ارائه‌دهنده استفاده کنید — در این صورت کل کد بالا به یک URL ثابت از دروازه کاهش می‌یابد و چرخش را پروکسی دیتاسنتر یا استخر مسکونی با چرخش بر اساس تایمر انجام می‌دهد.

اگر نیاز به چرخش فقط دایره‌ای نیست، بلکه وزن‌دار است — به عنوان مثال، اولویت دادن به پروکسی‌های سریع‌تر — وزن را به ساختار داده‌ها اضافه کنید:

const weightedProxies = [
  { url: 'http://user:[email protected]:8000', weight: 5 },
  { url: 'http://user:[email protected]:8000', weight: 1 },
];

function pickWeighted(list) {
  const total = list.reduce((sum, p) => sum + p.weight, 0);
  let rnd = Math.random() * total;
  for (const p of list) {
    if (rnd < p.weight) return p.url;
    rnd -= p.weight;
  }
  return list[0].url;
}

بررسی زنده بودن پروکسی و حذف IP‌های «مرده»

استخر پروکسی با گذشت زمان «فاسد» می‌شود: بخشی از IP‌ها توسط سایت هدف مسدود می‌شوند و بخشی دیگر دیگر پاسخ نمی‌دهند. برای اینکه چرخش درخواست‌ها از طریق آدرس‌های مرده نرود، یک بررسی زنده با تایم‌اوت از طریق AbortController داخلی اضافه کنید:

import { ProxyAgent, fetch } from 'undici';

async function isProxyAlive(proxyUrl, timeoutMs = 5000) {
  const agent = new ProxyAgent(proxyUrl);
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch('https://httpbin.org/ip', {
      dispatcher: agent,
      signal: controller.signal,
    });
    return res.status === 200;
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

async function filterAliveProxies(proxyList) {
  const checks = await Promise.all(
    proxyList.map(async (url) => ({ url, alive: await isProxyAlive(url) }))
  );
  return checks.filter((c) => c.alive).map((c) => c.url);
}

این بررسی را هر چند دقیقه یک بار با یک تایمر پس‌زمینه اجرا کنید و استخر فعال را به‌روزرسانی کنید، نه کل لیست ثابت. این موضوع به‌ویژه مهم است اگر شما پروکسی‌های خود را بر روی IP‌های ارزان دیتاسنتر نگه دارید — در آنجا درصد از دست رفتن بیشتر از استخرهای ارائه‌دهنده‌های مدیریت شده است.

SOCKS5 بدون کتابخانه‌های خارجی

یک نکته مهم: ProxyAgent داخلی در undici بر اساس تونل‌سازی HTTP CONNECT کار می‌کند و به طور پیش‌فرض فقط پروکسی‌های HTTP/HTTPS را درک می‌کند. پروتکل SOCKS5 به گونه‌ای دیگر طراحی شده است — این یک دست دادن باینری در سطح TCP است و undici به طور بومی آن را تجزیه نمی‌کند. اگر شما به شدت به SOCKS5 بدون نصب socks-proxy-agent نیاز دارید، می‌توانید یک کانکتور حداقلی را به صورت دستی از طریق ماژول net و گزینه connect در یک Agent سفارشی پیاده‌سازی کنید:

import net from 'node:net';
import { Agent, fetch } from 'undici';

function socksHandshake(socket, targetHost, targetPort) {
  return new Promise((resolve, reject) => {
    // مرحله 1: خوشامدگویی، روش بدون احراز هویت
    socket.write(Buffer.from([0x05, 0x01, 0x00]));

    socket.once('data', (greeting) => {
      if (greeting[1] !== 0x00) {
        return reject(new Error('SOCKS5: روش احراز هویت پشتیبانی نمی‌شود'));
      }

      const hostBuf = Buffer.from(targetHost, 'utf8');
      const request = Buffer.concat([
        Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
        hostBuf,
        Buffer.from([targetPort >> 8, targetPort & 0xff]),
      ]);

      socket.write(request);

      socket.once('data', (reply) => {
        if (reply[1] !== 0x00) {
          return reject(new Error('SOCKS5: اتصال رد شد'));
        }
        resolve(socket);
      });
    });
  });
}

const socksAgent = new Agent({
  connect: (options, callback) => {
    const socket = net.connect(1080, 'socks-proxy.example.com', async () => {
      try {
        await socksHandshake(socket, options.hostname, 443);
        callback(null, socket);
      } catch (err) {
        callback(err, null);
      }
    });
    socket.on('error', (err) => callback(err, null));
  },
});

const response = await fetch('https://httpbin.org/ip', { dispatcher: socksAgent });
console.log(await response.json());

این یک دست دادن ساده بدون احراز هویت با نام کاربری و رمز عبور (روش 0x02) و بدون پشتیبانی از IPv6 است — برای تولید، بهتر است یک شاخه با احراز هویت و پردازش انواع مختلف آدرس‌ها اضافه کنید. اما برای درک مکانیک — این کافی است: SOCKS5 در مرحله دست دادن به TLS درون تونل نیاز ندارد، بر خلاف HTTP CONNECT، بنابراین کد کوتاه‌تر از آنچه به نظر می‌رسد، است.

در عمل، SOCKS5 بیشتر برای تجزیه صفحات وب استفاده نمی‌شود، بلکه برای انتقال ترافیک برنامه‌های موبایل و مرورگرهای ضد شناسایی مانند Dolphin Anty یا AdsPower استفاده می‌شود، جایی که پروتکل برای کار در سطح سوکت‌ها و نه درخواست‌های HTTP نیاز است. اگر وظیفه شما دقیقاً ترافیک HTTP/HTTPS در Node.js است، ProxyAgent داخلی معمولاً کافی است و نیازی به دست و پنجه نرم کردن با کانکتور SOCKS5 دستی نیست.

مدیریت زمان‌های انتظار و منطق تلاش مجدد

پروکسی‌ها بیشتر از اتصالات مستقیم، اتصالات را قطع می‌کنند — این برای شبکه‌های مسکونی طبیعی است، جایی که گره ممکن است در هر لحظه به حالت آفلاین برود. به یک پوشش تلاش مجدد با تأخیر نمایی نیاز دارید:

import { ProxyAgent, fetch } from 'undici';

async function fetchWithRetry(url, proxyList, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const proxyUrl = proxyList[attempt % proxyList.length];
    const agent = new ProxyAgent(proxyUrl);
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 8000);

    try {
      const res = await fetch(url, { dispatcher: agent, signal: controller.signal });
      clearTimeout(timer);
      if (res.status >= 500) throw new Error(`خطای سرور: ${res.status}`);
      return res;
    } catch (err) {
      clearTimeout(timer);
      lastError = err;
      const delay = 500 * 2 ** attempt;
      await new Promise((r) => setTimeout(r, delay));
    }
  }

  throw new Error(`تمام تلاش‌ها به پایان رسید: ${lastError?.message}`);
}

این پوشش همچنین مشکل چرخش در صورت بروز خطا را حل می‌کند: اگر پروکسی فعلی از کار بیفتد، تلاش بعدی به طور خودکار به IP دیگری از لیست می‌رود و به همان گره مرده ضربه نمی‌زند.

مقایسه undici ProxyAgent، https-proxy-agent و socks-proxy-agent

ابزار وابستگی‌ها پشتیبانی از SOCKS5 HTTP/2 سرعت
undici ProxyAgent خیر (در Node 18+ گنجانده شده) خیر به طور پیش‌فرض بله بالا
https-proxy-agent 1-2 بسته خیر خیر متوسط
socks-proxy-agent 3-4 بسته بله خیر متوسط

برای تجزیه خالص HTTP/HTTPS با حداقل تعداد وابستگی‌ها، undici ProxyAgent به دلیل گنجاندن و سازگاری با HTTP/2 برتری دارد. برای SOCKS5 در تولید، بهتر است از socks-proxy-agent معتبر استفاده کنید تا اینکه یک دست دادن خود نوشته را نگهداری کنید — پیاده‌سازی دستی از بخش بالا برای یادگیری و موارد خاص نادر خوب است، اما برای تولید سنگین با هزاران اتصال در ساعت مناسب نیست.

موارد عملی: تجزیه و تست جغرافیایی

نظارت بر قیمت‌ها در بازارها. اسکریپت بر اساس undici با چرخش پروکسی و منطق تلاش مجدد از بخش‌های بالا به طور ایده‌آلی برای دور زدن محدودیت نرخ در Wildberries و Ozon مناسب است: هر درخواست بعدی کارت محصول با IP جدید ارسال می‌شود و بررسی زنده به طور خودکار آدرس‌های مسدود شده را از استخر حذف می‌کند.

آزمایش خلاقیت‌های تبلیغاتی بر اساس جغرافیا. اگر نیاز به بررسی اینکه چگونه محتوا به کاربر از یک کشور خاص برای Google Ads یا Facebook Ads ارائه می‌شود، کافی است URL پروکسی را در ProxyAgent به گره منطقه مورد نظر تغییر دهید — نیازی به راه‌اندازی مرورگر نیست، اگر بررسی از طریق API یا پاسخ HTML سرور انجام شود.

بررسی دسترسی محتوا در کشورهای مختلف. برای کارهایی که سایت محتوا را بسته به IP متفاوت ارائه می‌دهد، اتصالات مسکونی برای سرور هدف غیرقابل تشخیص از یک کاربر عادی به نظر می‌رسد، که خطر مسدود شدن را در مقایسه با دامنه‌های دیتاسنتر کاهش می‌دهد. این سناریو به‌ویژه برای پروکسی‌های موبایل که سرویس هدف نوع شبکه را بررسی می‌کند و دامنه‌های دیتاسنتر واضح را مسدود می‌کند، بسیار مهم است.

بررسی انبوه API-اندپوینت‌ها. در هنگام آزمایش بار API خود از طریق استخر پروکسی خارجی، مهم است که بر pooling اتصالات نظارت کنید — undici به طور پیش‌فرض اتصالات TCP را درون یک ProxyAgent دوباره استفاده می‌کند، که به طور قابل توجهی زمان را در دست دادن TLS در هنگام صدها درخواست متوالی به یک میزبان صرفه‌جویی می‌کند.

نتیجه‌گیری

undici ProxyAgent داخلی بیشتر وظایف مربوط به پروکسی‌های HTTP/HTTPS را در Node.js بدون نیاز به اضافه کردن بسته‌های خارجی پوشش می‌دهد: احراز هویت از طریق URL، تونل‌سازی از طریق CONNECT، سازگاری با HTTP/2 و pooling اتصالات به طور همزمان کار می‌کنند. چرخش و بررسی زنده پروکسی به راحتی با پرومیس‌های خالص و تایمرها جمع‌آوری می‌شود، و SOCKS5 در صورت نیاز به صورت دستی از طریق کانکتور سفارشی connect پیاده‌سازی می‌شود، اگرچه برای تولید معقول‌تر است که یک کتابخانه آماده مخصوص SOCKS را بگیرید.

اگر اسکریپت شما در undici برای تجزیه بازارها یا نظارت بر قیمت‌ها طراحی شده است، به پروکسی‌های مسکونی توجه کنید — آنها در برابر سیستم‌های ضد ربات مقاوم‌تر از IP‌های کلاسیک دیتاسنتر هستند و کمتر نیاز به حل مجدد کپچا در هنگام درخواست‌های انبوه به Wildberries، Ozon یا Avito دارند.