---
title: "استخراج داده از سایت با C#: HttpClient و پروکسی"
description: "برای استخراج داده با C# یک نمونه HttpClient بلندعمر می‌سازید، HTML را با HtmlAgilityPack تجزیه می‌کنید و پروکسی را همچون WebProxy به هندلر می‌دهید."
url: https://proxynet.io/fa/blog/csharp-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "آموزش‌ها, وب اسکرپینگ"
lang: fa
---

# استخراج داده از سایت با C#: HttpClient و پروکسی

در بیشتر نمونه‌های C# برای گرفتن داده هنوز `WebClient` و پانزده خط کد دیده می‌شود که یک صفحه را دانلود می‌کند. آن نمونه در نخستین اجرا کار می‌کند و سپس با بزرگ شدن کار از سه جای جداگانه می‌شکند: چون در هر درخواست یک شیء کلاینت تازه ساخته می‌شود سوکت‌ها تمام می‌شوند، سایتی که در هر ثانیه ده‌ها درخواست از یک نشانی می‌بیند `429` برمی‌گرداند، و کد تجزیه به‌محض تغییر ساختار صفحه با یک ارجاع تهی از کار می‌افتد. هیچ‌کدام از این سه به کتابخانه‌ای که انتخاب کرده‌اید مربوط نیست؛ هر سه از شیوه ساختن کلاینت می‌آید.

در این نوشته کار استخراج داده را روی ⁦.NET 8⁩ و نسخه‌های تازه‌تر می‌سازیم: چرخه عمر نمونه `HttpClient`، انتخاب میان HtmlAgilityPack و AngleSharp برای تجزیه HTML، تعریف پروکسی از راه `HttpClientHandler` یا `SocketsHttpHandler`، احراز هویت با نام کاربری و رمز عبور، نشانی‌های SOCKS5، مهلت زمانی و لغو، مرز همروندی و تلاش دوباره. هدف نمونه `books.toscrape.com` است؛ سایتی تمرینی که دقیقاً برای همین کار منتشر شده است.

> **نکته: پاسخ کوتاه**
>
> گرفتن داده از یک سایت با C# سه بخش دارد: دانلود صفحه با یک نمونه `HttpClient` که تا پایان عمر برنامه زنده می‌ماند، تجزیه HTML دریافتی با HtmlAgilityPack یا AngleSharp، و نوشتن رکوردها در یک فایل یا پایگاه داده. پروکسی از خود کلاینت وارد این زنجیره نمی‌شود، بلکه از هندلر زیر آن می‌آید: به ویژگی `Proxy` یک شیء `HttpClientHandler` یا `SocketsHttpHandler` یک `WebProxy` داده می‌شود و نام کاربری و رمز عبور هم به همان شیء `WebProxy` در قالب `NetworkCredential` افزوده می‌شود.

## کار اسکرپینگ با C# از چه گام‌هایی تشکیل می‌شود؟

جدا از زبان، هر کار اسکرپینگ همان پنج گام را دنبال می‌کند. در سمت C# هر گام این‌گونه است:

1. **ساختن فهرست نشانی‌ها.** صفحه‌های دسته‌بندی، پیوندهای صفحه‌بندی یا نقشه سایت. فهرست را همچون یک صف نگه دارید تا کاری که نیمه‌کاره می‌ماند از همان‌جا ادامه یابد.
2. **فرستادن درخواست.** `GetAsync` از راه `HttpClient`. کد وضعیت پاسخ بررسی و بدنه به متن تبدیل می‌شود.
3. **تجزیه HTML.** متن دریافتی به یک شیء سند تبدیل می‌شود و میدان‌هایی که می‌خواهید با انتخابگر XPath یا CSS پیدا می‌شوند.
4. **یکدست کردن داده.** نشانه واحد پول در قیمت، فاصله‌های اضافی در یک سطر و میدان‌های خالی همین‌جا پاک می‌شوند.
5. **ذخیره کردن.** JSON، CSV یا یک جدول پایگاه داده. اگر گام ذخیره جدا بماند، با هر تغییر در کد تجزیه نیازی به دانلود دوباره داده ندارید.

میان این گام‌ها دو چیز دیگر هم جای می‌گیرد: انتظار میان درخواست‌ها و رفتار هنگام خطا. هیچ‌کدام زینتی نیست که بعداً افزوده شود؛ هر دو باید در نخستین نسخه کار باشند. بخش‌های زیر آن‌ها را یکی‌یکی می‌سازند.

## پیش از شروع: robots.txt، سرعت و هویتی صادقانه برای کلاینت

پیش از فرستادن درخواست به هر سایتی، فایل `robots.txt` آن را ببینید. قالب فایل و قاعده‌های تفسیر آن با [⁦RFC 9309⁩](https://www.rfc-editor.org/rfc/rfc9309.html) استاندارد شد. استاندارد می‌گوید اگر فایل از سرور گرفته نشود خزنده می‌تواند به منابع دسترسی داشته باشد؛ یعنی نشانی‌ای که `404` برمی‌گرداند به معنای ممنوعیت نیست. هدف نمونه این نوشته یک مورد معمول است: `books.toscrape.com` سایتی ویترینی برای تمرین است، بنابراین فایل `robots.txt` ندارد و نشانی آن `404` برمی‌گرداند. شیوه خواندن قاعده‌ها را در [فایل robots.txt چیست و چگونه آن را بخوانیم؟](/fa/blog/robots-txt) به‌تفصیل نوشته‌ایم.

`robots.txt` سند اجازه نیست، تنها چیزی است که سایت به کلاینت‌های خودکار می‌گوید. بالاتر از آن دو مسئولیت دیگر هست. نخست سرعت: درخواست چندباره یک صفحه در هر ثانیه، روی سرور سایت در همان صفی می‌نشیند که درخواست کاربران واقعی. میان درخواست‌ها انتظاری آگاهانه بگذارید و شمار درخواست‌های هم‌زمان باز را محدود کنید. دوم هویت: مقدار `User-Agent` که می‌فرستید تنها سرنخ مدیری است که می‌خواهد با شما تماس بگیرد. نوشتن نام برنامه خودتان و یک نشانی تماس، هم صادقانه‌تر و هم کاربردی‌تر از رونویسی رشته یک مرورگر پرکاربرد است. کار این سرایند را در [User-Agent چیست و چگونه آن را ببینیم و تغییر دهیم؟](/fa/blog/what-is-user-agent) توضیح داده‌ایم.

مرز سومی هم هست و حقوقی است. صفحه‌های دارای داده شخصی، محتوای پشت ورود به حساب و کاربردهایی که شرایط سایت آشکارا ممنوع کرده است پرسش فنی نیستند. چارچوب این موضوع را در [آیا اسکرپینگ وب قانونی است؟](/fa/blog/is-data-web-scraping-legal) گرد آورده‌ایم.

## چرا HttpClient در هر درخواست دوباره ساخته نمی‌شود؟

رایج‌ترین خطای سمت C# همین‌جاست. چون شیء `HttpClient` از نوع `IDisposable` است، نمونه‌های بسیاری آن را درون بلوک `using` می‌سازند و در هر درخواست می‌بندند. [راهنمای کاربرد HttpClient](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines) مایکروسافت آشکارا می‌نویسد چرا این نادرست است: استخر اتصال درون شیء هندلر زیر کلاینت زندگی می‌کند و ساختن و دور انداختن کلاینت در هر درخواست، استخر را هم با خود می‌برد. چون درگاه اتصال‌های TCP بسته‌شده بی‌درنگ آزاد نمی‌شود، با بالا رفتن نرخ درخواست به مرز درگاه‌های در دسترس سیستم عامل می‌خورید. نشانه بیشتر به خرابی شبکه می‌ماند تا به خطای کد: کار مدتی بی‌اشکال پیش می‌رود و سپس خطاهای اتصال پشت سر هم می‌آیند.

درست آن است که یک کلاینت به‌کار ببرید که تا پایان عمر برنامه زنده بماند. تنها عیب یک کلاینت بلندعمر DNS است: `HttpClient` نام دامنه را تنها هنگام برقراری اتصال حل می‌کند و تغییرهای رکورد DNS را دنبال نمی‌کند. راهنما راه‌حل را هم می‌دهد: وقتی به ویژگی `PooledConnectionLifetime` یک شیء `SocketsHttpHandler` مدتی داده شود، اتصال استخر در پایان آن مدت بسته می‌شود و هنگام ساختن اتصال تازه DNS دوباره پرسیده می‌شود. در نمونه کد راهنما این مقدار پانزده دقیقه است، اما متن می‌گوید این عدد تنها برای نمایش انتخاب شده و مدت باید بر پایه بسامد مورد انتظار تغییرهای DNS تعیین شود؛ بازه‌ای که در بخش کاربرد پیشنهادی آمده دو دقیقه است.

راه دیگر `IHttpClientFactory` است. کارخانه شیءهای هندلر را در استخر خود نگه می‌دارد و هندلری را که عمرش به پایان نرسیده به کلاینت‌های تازه می‌سپارد؛ بنابراین مشکل سوکت باز هم پیش نمی‌آید. اگر در یک برنامه ASP.NET Core یا درون یک میزبان عمومی با تزریق وابستگی کار می‌کنید، کارخانه مناسب‌تر است. در یک اسکریپت کنسول مستقل، یک کلاینت `static` به‌همراه `PooledConnectionLifetime` بس است و قطعه‌های کمتری دارد.

یک نکته دیگر: تنظیم‌های اتصال هندلر پس از فرستادن نخستین درخواست تغییرپذیر نیست. اگر باید با پروکسی‌های گوناگون یا ظرف‌های کوکی جداگانه کار کنید، برای هر پیکربندی یک شیء کلاینت جداگانه می‌سازید. قاعده «یک کلاینت» یعنی «شیء تازه به‌ازای هر درخواست نه»، نه «در کل برنامه تنها یک شیء».

## میان HtmlAgilityPack و AngleSharp چگونه انتخاب کنیم؟

در ‎.NET‎ دو گزینه جاافتاده برای تجزیه HTML هست. هر دو بسته NuGet هستند، هر دو از پس HTML خراب برمی‌آیند، و تفاوت در زبان انتخابگر و مدل کار است.

| | HtmlAgilityPack | AngleSharp |
|---|---|---|
| زبان انتخابگر | XPath (`SelectNodes`، `SelectSingleNode`) | انتخابگر CSS (`QuerySelector`، `QuerySelectorAll`) |
| مدل DOM | مدل درختی خودش | نزدیک به API استاندارد DOM |
| تجزیه مستقیم متن | `HtmlDocument.LoadHtml(html)` | از راه بستر مرورگر |
| دانلود صفحه توسط خودش | ندارد، HTML را شما می‌دهید | دارد، با بارگذار پیش‌فرض |
| کاربرد معمول | تجزیه HTML دانلودشده با HttpClient | باز کردن صفحه و گشتن با انتخابگر CSS |

[نمونه بارگذاری از رشته](https://html-agility-pack.net/from-string) در مستندات خود HtmlAgilityPack این الگو را نشان می‌دهد: یک `HtmlDocument` ساخته می‌شود، رشته HTML با `LoadHtml` داده می‌شود و پرس‌وجوی XPath روی `DocumentNode` اجرا می‌شود (فراخوانی مستندات `SelectSingleNode` است؛ همتای آن برای چند گره `SelectNodes` است). AngleSharp هم [در نمونه مخزن خودش](https://github.com/AngleSharp/AngleSharp) نشانی را با بستری که با بارگذار پیش‌فرض ساخته شده خودش باز می‌کند و با `QuerySelectorAll` انتخابگر CSS را به کار می‌برد.

انتخاب بیشتر به عادت بستگی دارد. اگر درخواست را همین حالا با `HttpClient` می‌فرستید، یک رشته HTML در دست دارید و راه `LoadHtml` از HtmlAgilityPack کوتاه‌ترین راه است؛ نمونه‌های این نوشته هم از آن بهره می‌برند. اگر می‌خواهید انتخابگر CSS آزموده در کنسول مرورگر را بی‌تغییر به کد ببرید، AngleSharp راحت‌تر است. مقایسه این دو زبان انتخابگر را در [انتخابگر CSS یا XPath](/fa/blog/css-selector-vs-xpath) انجام داده‌ایم و اینجا تکرارش نمی‌کنیم.

هیچ‌کدام از این دو کتابخانه جاوااسکریپت را اجرا نمی‌کند. اگر داده تنها با اسکریپتی که در مرورگر اجرا می‌شود می‌آید، به تجزیه‌گر نیاز ندارید بلکه به خودکارسازی مرورگر نیاز دارید: [صفحه‌های ایستا و پویا](/fa/blog/static-vs-dynamic-pages) این تمایز را و [مقایسه Playwright و Selenium](/fa/blog/playwright-vs-selenium) این دو ابزار را کنار هم می‌گذارد.

## نمونه نخست: گرفتن و تجزیه یک صفحه

نمونه زیر یک صفحه از فهرست کتاب‌ها را دانلود می‌کند و نام و قیمت کتاب‌ها را چاپ می‌کند. کد بر پایه قالب کنسول ⁦.NET 8⁩ و پس از آن نوشته شده، با دستورهای سطح بالا و راهنماهای `using` ضمنی؛ به همین دلیل `System`، `System.Linq` و `System.Net.Http` جداگانه نوشته نشده‌اند.

```csharp
using HtmlAgilityPack;

// The connection pool lives inside the handler; we build the client once and reuse it.
var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2) // connection lifetime, for DNS changes
};

using var client = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(30)
};

// An honest identity that says who we are and how to reach us.
client.DefaultRequestHeaders.UserAgent.ParseAdd("BookTracker/1.0 (+https://ornek.com/bot)");

var url = "https://books.toscrape.com/catalogue/page-1.html";

using var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode(); // throws HttpRequestException on codes outside 2xx

var html = await response.Content.ReadAsStringAsync();

var document = new HtmlDocument();
document.LoadHtml(html);

// SelectNodes returns null when no node matches, so we check first.
var cards = document.DocumentNode.SelectNodes("//article[contains(@class,'product_pod')]");
if (cards is null)
{
    Console.WriteLine("Page structure differs from what we expected: no product card found.");
    return;
}

foreach (var card in cards)
{
    var title = card.SelectSingleNode(".//h3/a")?.GetAttributeValue("title", "");
    var price = card.SelectSingleNode(".//p[@class='price_color']")?.InnerText.Trim();
    Console.WriteLine($"{title} | {price}");
}
```

به سه نکته توجه کنید. `EnsureSuccessStatusCode` وقتی کد وضعیت بیرون از بازه `200-299` باشد `HttpRequestException` پرتاب می‌کند؛ اگر می‌خواهید خودتان کد را بخوانید به‌جای آن ویژگی `IsSuccessStatusCode` را ببینید. `SelectNodes` وقتی تطابقی نیابد مجموعه خالی برنمی‌گرداند بلکه `null` می‌دهد؛ این رفتار در کدی که نتیجه را مستقیم درون `foreach` می‌گذارد به شکل `NullReferenceException` بازمی‌گردد. سرانجام نشانه `?` در پایان فراخوانی‌های `SelectSingleNode` جلوی آن را می‌گیرد که نبود یک میدان در یک کارت، کل کار را بخواباند.

## چگونه به HttpClient پروکسی اضافه می‌شود؟

تنظیم پروکسی روی کلاینت نیست، روی هندلر زیر آن است. ویژگی `HttpClientHandler.Proxy` شیئی از نوع `IWebProxy` می‌گیرد و مقدار پیش‌فرض آن `null` است؛ یعنی اگر چیزی ندهید ‎.NET‎ تنظیم پروکسی سیستم عامل یا متغیرهای محیطی را به کار می‌برد. برای پروکسی‌ای که با نام کاربری و رمز عبور کار می‌کند یک شیء `WebProxy` ساخته می‌شود و اطلاعات هویت در قالب `NetworkCredential` به ویژگی `Credentials` همان شیء داده می‌شود.

```csharp
using System.Net;

var proxy = new WebProxy("http://pr.proxynet.io:8000")
{
    // The username and password from the panel; don't hard-code them, read them from settings.
    Credentials = new NetworkCredential("user", "pass")
};

var handler = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),
    MaxConnectionsPerServer = 4 // number of concurrent connections opened to the same server
};

using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };

using var response = await client.GetAsync("https://books.toscrape.com/");
Console.WriteLine((int)response.StatusCode);
```

کلاس `SocketsHttpHandler` در کنار ویژگی‌های `Proxy` و `UseProxy` تنظیم‌هایی مانند `PooledConnectionLifetime`، `MaxConnectionsPerServer`، `ConnectTimeout` و `AutomaticDecompression` را هم دارد؛ به همین دلیل کدی که می‌خواهد پروکسی و تنظیم استخر اتصال را با هم به کار ببرد معمولاً به‌جای `HttpClientHandler` آن را برمی‌گزیند.

دو هشدار. نخست اینکه اگر رمز عبور نویسه‌هایی مانند `@` یا `:` دارد، آن‌ها را درون رشته نشانی جاسازی نکنید؛ وقتی به `NetworkCredential` در میدان‌های جداگانه داده شود دردسر کدگذاری از میان می‌رود. دوم اینکه وقتی احراز هویت پروکسی ناکام می‌ماند، پاسخ `407` نه از سایت مقصد بلکه از پروکسی می‌آید؛ یعنی مشکل در قاعده‌های سایت نیست، در پیکربندی شماست. دو روش احراز هویت و تشخیص این خطا را در [احراز هویت پروکسی](/fa/blog/proxy-authentication-methods) نوشته‌ایم.

اینکه با کدام گونه پروکسی کار می‌کنید به خود کار بستگی دارد. در کارهای پرحجم روی محیط آزمون خودتان یا منابع بدون محدودیت دسترسی، [پروکسی دیتاسنتر](https://proxynet.io/fa/datacenter-proxy) از نظر سرعت برتری دارد. در هدف‌هایی که بازه‌های دیتاسنتر را می‌بندند [پروکسی مسکونی](https://proxynet.io/fa/residential-proxy) برگزیده می‌شود که نقطه خروج آن به یک ارائه‌دهنده اینترنت تعلق دارد؛ تفاوت این دو گروه در سطح ASN را در [تفاوت پروکسی ISP و پروکسی مسکونی](/fa/blog/isp-vs-residential-proxy) توضیح داده‌ایم.

## پروکسی SOCKS5 در C# چگونه داده می‌شود؟

در کنار پروکسی HTTP می‌توان SOCKS را هم به کار برد. مستندات [HttpClient.DefaultProxy](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.defaultproxy) مایکروسافت طرح‌های پشتیبانی‌شده را یکی‌یکی می‌شمارد: `http`، `https`، `socks4`، `socks4a` و `socks5`. همان صفحه قالب نشانی‌ها را هم می‌دهد؛ الگوی `socks5` به شکل `socks5://[user:pass@]host[:port]` است. در سطرهای `socks4` و `socks4a` نوشته شده که رمز عبور نادیده گرفته می‌شود، چون این پروتکل‌ها احراز هویت با رمز عبور ندارند.

`DefaultProxy` ویژگی ایستایی است که برای همه کلاینت‌هایی معتبر است که در هندلرشان پروکسی صریح داده نشده. نمونه پیش‌فرض آن نخست از متغیرهای محیطی ساخته می‌شود؛ اگر این متغیرها تعریف نشده باشند، در ویندوز و مک‌اواس به تنظیم پروکسی سیستم عامل رجوع می‌شود و در لینوکس نمونه‌ای بدون پروکسی به دست می‌آید. متغیرهای یادشده در مستندات `HTTP_PROXY`، `HTTPS_PROXY`، `ALL_PROXY` و `NO_PROXY` هستند که دامنه‌های کنارگذاشتنی را نگه می‌دارد. پس می‌توان کد را بی‌هیچ تغییری این‌گونه اجرا کرد:

```powershell
# Before starting the application (Windows PowerShell)
$env:ALL_PROXY = "socks5://user:pass@pr.proxynet.io:1080"
dotnet run
```

جزئیات دادن پروکسی با متغیرهای محیطی و همتاهای آن در ابزارهای دیگر را در [استفاده از پروکسی در wget](/fa/blog/wget-proxy) گرد آورده‌ایم. خود پروتکل، تفاوت‌های آن با پروکسی HTTP و اینکه کدام کار با کدام جور درمی‌آید در صفحه [پروکسی SOCKS5](https://proxynet.io/fa/socks5-proxy) ما آمده است.

## مهلت زمانی، لغو و تلاش دوباره چگونه تنظیم می‌شود؟

مقدار پیش‌فرض ویژگی `HttpClient.Timeout` صد ثانیه است و بر همه درخواست‌هایی که از آن کلاینت بیرون می‌روند اعمال می‌شود. برای یک کار اسکرپینگ این مدت بسیار طولانی است: یک نشانی که پاسخ نمی‌دهد جریان کار را یک دقیقه و نیم نگه می‌دارد. مقداری در حدود سی ثانیه برای بیشتر صفحه‌ها کافی است.

اگر برای درخواست‌های تک‌تک مدت دیگری لازم است، `CancellationTokenSource` به کار می‌رود. مستندات مایکروسافت می‌گوید این دو سازوکار با هم کار می‌کنند و **کوتاه‌تر** آن دو معتبر است. همان توکن برای گذاشتن سقف روی کل کار هم به کار می‌آید: وقتی کاربر کار را متوقف کند یا مدت کل از حد بگذرد، همه درخواست‌های در انتظار از یک نقطه لغو می‌شوند.

لازم نیست منطق تلاش دوباره را از صفر بنویسید. بسته NuGet با نام `Microsoft.Extensions.Http.Resilience` از مایکروسافت یک هندلر تاب‌آوری آماده برای `HttpClient` می‌دهد؛ با فراخوانی `AddStandardResilienceHandler` روی `IHttpClientBuilder` به‌ترتیب لایه‌های محدودکننده نرخ، مهلت کل، تلاش دوباره، قطع‌کننده مدار و مهلت هر تلاش وارد کار می‌شوند. راهبرد پیش‌فرض تلاش دوباره دست‌بالا سه بار تلاش می‌کند و از عقب‌نشینی نمایی با مؤلفه تصادفی بهره می‌برد؛ از میان پاسخ‌هایی که رسیدگی می‌کند `408`، `429` و کدهای `500` و بالاتر هستند. شیوه راه‌اندازی این بسته با کلاینت‌های تکی که تزریق وابستگی ندارند هم در راهنمای کاربرد با یک نمونه نشان داده شده است.

اینکه در کدام کد وضعیت تلاش دوباره معنا دارد موضوعی جداست و تصمیم آن نه با کتابخانه بلکه با شماست: `429` و `503` با انتظار درست می‌شوند، `403` و `407` نه. خواندن سرایند `Retry-After`، ساختن درست عقب‌نشینی نمایی و نمونه کامل حلقه‌ای که بر پایه کدها تصمیم می‌گیرد در [کدهای وضعیت HTTP در وب اسکرپینگ](/fa/blog/http-status-codes-web-scraping) آمده؛ آن کد را اینجا تکرار نمی‌کنیم.

## نمونه کامل: کاری که صفحه‌ها را به ترتیب می‌پیماید

نمونه زیر ده صفحه نخست فهرست را از راه پروکسی می‌پیماید، شمار درخواست‌های هم‌زمان را به دو محدود می‌کند، پیش از هر درخواست منتظر می‌ماند و نتیجه را در یک فایل JSON می‌نویسد.

```csharp
using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using HtmlAgilityPack;

var proxy = new WebProxy("http://pr.proxynet.io:8000")
{
    Credentials = new NetworkCredential("user", "pass")
};

var handler = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),
    MaxConnectionsPerServer = 4
};

using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("BookTracker/1.0 (+https://ornek.com/bot)");

// Upper bound for the whole job: when the time is up, every pending request is cancelled.
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
var token = cts.Token;

var gate = new SemaphoreSlim(2); // at most two requests at a time
var results = new ConcurrentBag<Book>();

var tasks = Enumerable.Range(1, 10).Select(async page =>
{
    await gate.WaitAsync(token);
    try
    {
        // We put a deliberate wait between requests.
        await Task.Delay(TimeSpan.FromMilliseconds(500), token);

        var url = $"https://books.toscrape.com/catalogue/page-{page}.html";
        using var response = await client.GetAsync(url, token);

        if (!response.IsSuccessStatusCode)
        {
            Console.WriteLine($"{url}: HTTP {(int)response.StatusCode}");
            return;
        }

        var html = await response.Content.ReadAsStringAsync(token);
        foreach (var book in ParseBooks(html, page))
        {
            results.Add(book);
        }
    }
    finally
    {
        gate.Release();
    }
}).ToList();

await Task.WhenAll(tasks);

var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(results.OrderBy(book => book.Page), options);
await File.WriteAllTextAsync("books.json", json, token);

Console.WriteLine($"{results.Count} records written.");

// Parsing sits in its own function: when the page structure changes, only this part changes.
static IEnumerable<Book> ParseBooks(string html, int page)
{
    var document = new HtmlDocument();
    document.LoadHtml(html);

    var cards = document.DocumentNode.SelectNodes("//article[contains(@class,'product_pod')]");
    if (cards is null)
    {
        yield break;
    }

    foreach (var card in cards)
    {
        var title = card.SelectSingleNode(".//h3/a")?.GetAttributeValue("title", "") ?? "";
        var price = card.SelectSingleNode(".//p[@class='price_color']")?.InnerText.Trim() ?? "";
        yield return new Book(title, price, page);
    }
}

record Book(string Title, string Price, int Page);
```

`SemaphoreSlim` تعیین می‌کند چند درخواست هم‌زمان در پرواز است؛ `Task.WhenAll` هرچند همه وظیفه‌های فهرست را آغاز کند، سمافور اجازه نمی‌دهد بیش از دو تا با هم پیش بروند. این را با تنظیم `MaxConnectionsPerServer` هندلر اشتباه نگیرید: یکی شمار اتصال‌های TCP گشودنی را محدود می‌کند و دیگری شمار کارهای هم‌زمانی را که کد شما می‌سازد. تفاوت مفهومی این دو را در [همروندی و موازی‌سازی](/fa/blog/concurrency-vs-parallelism) بررسی کرده‌ایم.

نمونه یک بازه ثابت از صفحه‌ها را می‌پیماید. در کار واقعی شمار صفحه‌ها از پیش دانسته نیست، پیوند «بعدی» دنبال می‌شود و نشانی‌های پیموده‌شده در یک صف نگه داشته می‌شوند. این چیدمان را در [صفحه‌بندی در وب اسکرپینگ](/fa/blog/pagination-web-scraping) می‌سازیم.

## کاربردها

- **پایش قیمت و موجودی.** پیمایش منظم محصولات خودتان و نمونه‌های مشابه در فهرست‌های باز. چیدمان کلی در [راهکار پایش قیمت ما](/fa/price-monitoring) آمده است.
- **کارهای سازمانی گردآوری داده.** تیم‌های بانک، بیمه و ERP که با C# کار می‌کنند داده گردآمده را همین حالا در پایگاه داده‌ای در سمت ‎.NET‎ می‌نویسند؛ ماندن در همان راهکار به‌جای افزودن زبانی تازه هزینه نگهداری را پایین می‌آورد. برای کارهای گسترده [راهکار استخراج داده ما](/fa/data-scraping) نقطه آغاز است.
- **پیمایش منظم منابع پرشمار.** تا بار روی یک نقطه خروج انباشته نشود، [پروکسی چرخشی](https://proxynet.io/fa/rotating-proxy) به کار می‌رود.
- **پنل خودتان که نشست می‌خواهد.** اگر در میانه یک نشست واردشده IP تغییر کند، نشست می‌افتد؛ در چنین کارهایی [پروکسی با نشست ثابت](https://proxynet.io/fa/sticky-proxy) برگزیده می‌شود.
- **بررسی محتوای وابسته به منطقه.** مقایسه اینکه همان صفحه از کشورهای گوناگون چگونه دیده می‌شود با تغییر مکان خروج شدنی است.

## خطاهای رایج

- **ساختن `HttpClient` تازه در هر درخواست.** نیمه نخست کار بی‌اشکال می‌گذرد و سپس خطاهای اتصال آغاز می‌شود. چون نشانه به خرابی شبکه می‌ماند، تشخیص معمولاً در جای نادرست جست‌وجو می‌شود.
- **ساختن کلاینت تکی بدون `PooledConnectionLifetime`.** در سرویسی که مدت‌ها اجرا می‌شود، وقتی رکورد DNS هدف یا پروکسی تغییر کند کلاینت همچنان به نشانی کهنه می‌رود.
- **بررسی نکردن نتیجه `SelectNodes`.** اگر تطابقی نباشد مقدار بازگشتی `null` است؛ `foreach` این مقدار را خاموش رد نمی‌کند، استثنا پرتاب می‌کند.
- **درست شمردن کد `200` بی‌آنکه بدنه پاسخ دیده شود.** صفحه‌های تأیید هم با `200` می‌آیند. مطمئن شوید عنصری که انتظارش را دارید در صفحه هست.
- **آغاز هم‌زمان همه نشانی‌ها بدون انتظار.** `Task.WhenAll` صدها وظیفه فهرست را بی‌اعتراض آغاز می‌کند؛ کسی که باید مرز بگذارد شمایید.
- **نوشتن اطلاعات هویت درون کد.** نام کاربری و رمز عبور پروکسی از فایل تنظیم یا متغیر محیطی خوانده می‌شود و به مخزن راه نمی‌یابد.
- **جاسازی رمز عبور در رشته نشانی و فراموشی کدگذاری.** رمزی که `@` دارد بقیه نشانی را خراب می‌کند؛ `NetworkCredential` این مشکل را از میان برمی‌دارد.

## راهنمای انتخاب

| نیاز | پیشنهاد |
|---|---|
| کار کنسولی مستقل | یک `HttpClient` تکی `static` به‌همراه `PooledConnectionLifetime` |
| برنامه دارای تزریق وابستگی | `IHttpClientFactory`، ترجیحاً با کلاینت نوع‌دار |
| تجزیه با XPath | HtmlAgilityPack، `LoadHtml` به‌همراه `SelectNodes` |
| انتخابگر CSS آزموده در مرورگر | AngleSharp، `QuerySelectorAll` |
| محتوایی که با جاوااسکریپت می‌آید | خودکارسازی مرورگر، نه تجزیه‌گر |
| پروکسی با نام کاربری و رمز عبور | `WebProxy` به‌همراه `NetworkCredential` |
| خروج SOCKS5 | نشانی `socks5://`، متغیر محیطی یا `DefaultProxy` |
| تلاش دوباره و قطع‌کننده مدار | `Microsoft.Extensions.Http.Resilience` |
| مرز همروندی | `SemaphoreSlim` به‌همراه `MaxConnectionsPerServer` |

## پرسش‌های متداول

### برای گرفتن داده با C# چه بسته‌هایی باید نصب کنم؟

در سمت HTTP بسته افزوده‌ای لازم نیست، `System.Net.Http` بخشی از زمان اجراست. برای تجزیه HTML یک بسته NuGet می‌افزایید: اگر با XPath کار می‌کنید HtmlAgilityPack و اگر با انتخابگرهای CSS کار می‌کنید AngleSharp. اگر تلاش دوباره و قطع‌کننده مدار می‌خواهید بسته `Microsoft.Extensions.Http.Resilience` افزوده می‌شود. برای نوشتن داده در قالب JSON، `System.Text.Json` همراه است.

### چرا نباید شیء HttpClient را درون بلوک `using` به کار ببرم؟

چون استخر اتصال درون هندلر زیر کلاینت است. ساختن و بستن کلاینت تازه در هر درخواست استخر را هم می‌بندد و چون درگاه اتصال‌های TCP بسته‌شده بی‌درنگ آزاد نمی‌شود، در کاری پرترافیک به مرز درگاه‌های در دسترس می‌خورید. درست آن است که یک کلاینت به‌کار ببرید که تا پایان عمر برنامه زنده بماند، یا آن را با `IHttpClientFactory` مدیریت کنید.

### پروکسی را تعریف کردم اما خطای 407 می‌گیرم، کجا را بگردم؟

`407` نه از سایت مقصد بلکه از سرور پروکسی می‌آید و می‌گوید هویت شما تأیید نشده است. نخست بررسی کنید ویژگی `Credentials` شیء `WebProxy` پر باشد، سپس درستی نام کاربری و رمز عبور را وارسی کنید. اگر با روش مجوز IP کار می‌کنید، نشانی IP عمومی که درخواست از آن بیرون می‌رود باید در فهرست باشد. تلاش دوباره این خطا را حل نمی‌کند.

### آیا در سمت C# می‌توان پروکسی SOCKS5 به کار برد؟

بله. مستندات `HttpClient.DefaultProxy` مایکروسافت `http`، `https`، `socks4`، `socks4a` و `socks5` را در فهرست قالب‌های نشانی پشتیبانی‌شده می‌آورد. الگوی `socks5` به شکل `socks5://user:pass@host:port` است. در همان صفحه نوشته شده که رمز عبور داده‌شده با `socks4` و `socks4a` نادیده گرفته می‌شود، چون در آن پروتکل‌ها احراز هویت با رمز عبور نیست.

### با تغییر ساختار صفحه کدم می‌شکند، چه کنم؟

جلوی شکستن را نمی‌توانید بگیرید، اما می‌توانید زود متوجه شوید. تجزیه را در تابعی جداگانه نگه دارید، `null` شدن نتیجه `SelectNodes` را همچون خطا در گزارش بنویسید و در هر اجرا شمار رکوردهای یافته را ثبت کنید. رکوردهایی بسیار کمتر از انتظار، نخستین نشانه تغییر ساختار است. کوتاه و بامعنا نگه داشتن انتخابگرها هم شکنندگی را کم می‌کند.

### چه زمانی باید به Selenium یا Playwright بروم؟

وقتی داده‌ای که می‌خواهید در HTML دانلودشده اصلاً نباشد. صفحه را در ابزارهای توسعه‌دهنده مرورگر باز کنید و کد منبع را ببینید: اگر داده در منبع باشد `HttpClient` بس است و خودکارسازی مرورگر تنها کندی می‌آورد. اگر داده بعداً با درخواستی دیگر می‌آید، بیشتر وقت‌ها تمیزترین راه فراخوانی مستقیم نشانی همان درخواست است. اگر هیچ‌کدام نشد، به راه [Selenium با پروکسی](/fa/blog/selenium) می‌روید.

## خلاصه

بخش دشوار گرفتن داده با C# تجزیه نیست، برپا کردن کلاینت است. یک نمونه `HttpClient` که تا پایان عمر برنامه زنده بماند، عمر اتصالی که با `PooledConnectionLifetime` محدود شده و مهلتی در حدود سی ثانیه، کار را در حال رشد سرپا نگه می‌دارد. پروکسی را در قالب `WebProxy` به ویژگی `Proxy` هندلر می‌دهید و اطلاعات هویت را با `NetworkCredential` در میدان‌های جداگانه نگه می‌دارید. مرز سرعت را با `SemaphoreSlim` در کد خودتان می‌سازید و تلاش دوباره را به یک هندلر تاب‌آوری آماده می‌سپارید. نگه داشتن تجزیه در تابعی جداگانه هم سبب می‌شود با تغییر ساختار صفحه تنها یک جا را درست کنید. گونه‌های پروکسی مناسب کارهای گردآوری داده‌تان را در [خدمات پروکسی ما](/fa/proxy) می‌یابید.
