در بیشتر نمونههای C# برای گرفتن داده هنوز WebClient و پانزده خط کد دیده میشود که یک صفحه را دانلود میکند. آن نمونه در نخستین اجرا کار میکند و سپس با بزرگ شدن کار از سه جای جداگانه میشکند: چون در هر درخواست یک شیء کلاینت تازه ساخته میشود سوکتها تمام میشوند، سایتی که در هر ثانیه دهها درخواست از یک نشانی میبیند 429 برمیگرداند، و کد تجزیه بهمحض تغییر ساختار صفحه با یک ارجاع تهی از کار میافتد. هیچکدام از این سه به کتابخانهای که انتخاب کردهاید مربوط نیست؛ هر سه از شیوه ساختن کلاینت میآید.
در این نوشته کار استخراج داده را روی .NET 8 و نسخههای تازهتر میسازیم: چرخه عمر نمونه HttpClient، انتخاب میان HtmlAgilityPack و AngleSharp برای تجزیه HTML، تعریف پروکسی از راه HttpClientHandler یا SocketsHttpHandler، احراز هویت با نام کاربری و رمز عبور، نشانیهای SOCKS5، مهلت زمانی و لغو، مرز همروندی و تلاش دوباره. هدف نمونه books.toscrape.com است؛ سایتی تمرینی که دقیقاً برای همین کار منتشر شده است.
کار اسکرپینگ با C# از چه گامهایی تشکیل میشود؟
جدا از زبان، هر کار اسکرپینگ همان پنج گام را دنبال میکند. در سمت C# هر گام اینگونه است:
- ساختن فهرست نشانیها. صفحههای دستهبندی، پیوندهای صفحهبندی یا نقشه سایت. فهرست را همچون یک صف نگه دارید تا کاری که نیمهکاره میماند از همانجا ادامه یابد.
- فرستادن درخواست.
GetAsyncاز راهHttpClient. کد وضعیت پاسخ بررسی و بدنه به متن تبدیل میشود. - تجزیه HTML. متن دریافتی به یک شیء سند تبدیل میشود و میدانهایی که میخواهید با انتخابگر XPath یا CSS پیدا میشوند.
- یکدست کردن داده. نشانه واحد پول در قیمت، فاصلههای اضافی در یک سطر و میدانهای خالی همینجا پاک میشوند.
- ذخیره کردن. JSON، CSV یا یک جدول پایگاه داده. اگر گام ذخیره جدا بماند، با هر تغییر در کد تجزیه نیازی به دانلود دوباره داده ندارید.
میان این گامها دو چیز دیگر هم جای میگیرد: انتظار میان درخواستها و رفتار هنگام خطا. هیچکدام زینتی نیست که بعداً افزوده شود؛ هر دو باید در نخستین نسخه کار باشند. بخشهای زیر آنها را یکییکی میسازند.
پیش از شروع: robots.txt، سرعت و هویتی صادقانه برای کلاینت
پیش از فرستادن درخواست به هر سایتی، فایل robots.txt آن را ببینید. قالب فایل و قاعدههای تفسیر آن با RFC 9309 استاندارد شد. استاندارد میگوید اگر فایل از سرور گرفته نشود خزنده میتواند به منابع دسترسی داشته باشد؛ یعنی نشانیای که 404 برمیگرداند به معنای ممنوعیت نیست. هدف نمونه این نوشته یک مورد معمول است: books.toscrape.com سایتی ویترینی برای تمرین است، بنابراین فایل robots.txt ندارد و نشانی آن 404 برمیگرداند. شیوه خواندن قاعدهها را در فایل robots.txt چیست و چگونه آن را بخوانیم؟ بهتفصیل نوشتهایم.
robots.txt سند اجازه نیست، تنها چیزی است که سایت به کلاینتهای خودکار میگوید. بالاتر از آن دو مسئولیت دیگر هست. نخست سرعت: درخواست چندباره یک صفحه در هر ثانیه، روی سرور سایت در همان صفی مینشیند که درخواست کاربران واقعی. میان درخواستها انتظاری آگاهانه بگذارید و شمار درخواستهای همزمان باز را محدود کنید. دوم هویت: مقدار User-Agent که میفرستید تنها سرنخ مدیری است که میخواهد با شما تماس بگیرد. نوشتن نام برنامه خودتان و یک نشانی تماس، هم صادقانهتر و هم کاربردیتر از رونویسی رشته یک مرورگر پرکاربرد است. کار این سرایند را در User-Agent چیست و چگونه آن را ببینیم و تغییر دهیم؟ توضیح دادهایم.
مرز سومی هم هست و حقوقی است. صفحههای دارای داده شخصی، محتوای پشت ورود به حساب و کاربردهایی که شرایط سایت آشکارا ممنوع کرده است پرسش فنی نیستند. چارچوب این موضوع را در آیا اسکرپینگ وب قانونی است؟ گرد آوردهایم.
چرا HttpClient در هر درخواست دوباره ساخته نمیشود؟
رایجترین خطای سمت C# همینجاست. چون شیء HttpClient از نوع IDisposable است، نمونههای بسیاری آن را درون بلوک using میسازند و در هر درخواست میبندند. راهنمای کاربرد HttpClient مایکروسافت آشکارا مینویسد چرا این نادرست است: استخر اتصال درون شیء هندلر زیر کلاینت زندگی میکند و ساختن و دور انداختن کلاینت در هر درخواست، استخر را هم با خود میبرد. چون درگاه اتصالهای 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 |
نمونه بارگذاری از رشته در مستندات خود HtmlAgilityPack این الگو را نشان میدهد: یک HtmlDocument ساخته میشود، رشته HTML با LoadHtml داده میشود و پرسوجوی XPath روی DocumentNode اجرا میشود (فراخوانی مستندات SelectSingleNode است؛ همتای آن برای چند گره SelectNodes است). AngleSharp هم در نمونه مخزن خودش نشانی را با بستری که با بارگذار پیشفرض ساخته شده خودش باز میکند و با QuerySelectorAll انتخابگر CSS را به کار میبرد.
انتخاب بیشتر به عادت بستگی دارد. اگر درخواست را همین حالا با HttpClient میفرستید، یک رشته HTML در دست دارید و راه LoadHtml از HtmlAgilityPack کوتاهترین راه است؛ نمونههای این نوشته هم از آن بهره میبرند. اگر میخواهید انتخابگر CSS آزموده در کنسول مرورگر را بیتغییر به کد ببرید، AngleSharp راحتتر است. مقایسه این دو زبان انتخابگر را در انتخابگر CSS یا XPath انجام دادهایم و اینجا تکرارش نمیکنیم.
هیچکدام از این دو کتابخانه جاوااسکریپت را اجرا نمیکند. اگر داده تنها با اسکریپتی که در مرورگر اجرا میشود میآید، به تجزیهگر نیاز ندارید بلکه به خودکارسازی مرورگر نیاز دارید: صفحههای ایستا و پویا این تمایز را و مقایسه Playwright و Selenium این دو ابزار را کنار هم میگذارد.
نمونه نخست: گرفتن و تجزیه یک صفحه
نمونه زیر یک صفحه از فهرست کتابها را دانلود میکند و نام و قیمت کتابها را چاپ میکند. کد بر پایه قالب کنسول .NET 8 و پس از آن نوشته شده، با دستورهای سطح بالا و راهنماهای using ضمنی؛ به همین دلیل System، System.Linq و System.Net.Http جداگانه نوشته نشدهاند.
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 همان شیء داده میشود.
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 نه از سایت مقصد بلکه از پروکسی میآید؛ یعنی مشکل در قاعدههای سایت نیست، در پیکربندی شماست. دو روش احراز هویت و تشخیص این خطا را در احراز هویت پروکسی نوشتهایم.
اینکه با کدام گونه پروکسی کار میکنید به خود کار بستگی دارد. در کارهای پرحجم روی محیط آزمون خودتان یا منابع بدون محدودیت دسترسی، پروکسی دیتاسنتر از نظر سرعت برتری دارد. در هدفهایی که بازههای دیتاسنتر را میبندند پروکسی مسکونی برگزیده میشود که نقطه خروج آن به یک ارائهدهنده اینترنت تعلق دارد؛ تفاوت این دو گروه در سطح ASN را در تفاوت پروکسی ISP و پروکسی مسکونی توضیح دادهایم.
پروکسی SOCKS5 در C# چگونه داده میشود؟
در کنار پروکسی HTTP میتوان SOCKS را هم به کار برد. مستندات HttpClient.DefaultProxy مایکروسافت طرحهای پشتیبانیشده را یکییکی میشمارد: http، https، socks4، socks4a و socks5. همان صفحه قالب نشانیها را هم میدهد؛ الگوی socks5 به شکل socks5://[user:pass@]host[:port] است. در سطرهای socks4 و socks4a نوشته شده که رمز عبور نادیده گرفته میشود، چون این پروتکلها احراز هویت با رمز عبور ندارند.
DefaultProxy ویژگی ایستایی است که برای همه کلاینتهایی معتبر است که در هندلرشان پروکسی صریح داده نشده. نمونه پیشفرض آن نخست از متغیرهای محیطی ساخته میشود؛ اگر این متغیرها تعریف نشده باشند، در ویندوز و مکاواس به تنظیم پروکسی سیستم عامل رجوع میشود و در لینوکس نمونهای بدون پروکسی به دست میآید. متغیرهای یادشده در مستندات HTTP_PROXY، HTTPS_PROXY، ALL_PROXY و NO_PROXY هستند که دامنههای کنارگذاشتنی را نگه میدارد. پس میتوان کد را بیهیچ تغییری اینگونه اجرا کرد:
# Before starting the application (Windows PowerShell)
$env:ALL_PROXY = "socks5://user:pass@pr.proxynet.io:1080"
dotnet runجزئیات دادن پروکسی با متغیرهای محیطی و همتاهای آن در ابزارهای دیگر را در استفاده از پروکسی در wget گرد آوردهایم. خود پروتکل، تفاوتهای آن با پروکسی HTTP و اینکه کدام کار با کدام جور درمیآید در صفحه پروکسی SOCKS5 ما آمده است.
مهلت زمانی، لغو و تلاش دوباره چگونه تنظیم میشود؟
مقدار پیشفرض ویژگی HttpClient.Timeout صد ثانیه است و بر همه درخواستهایی که از آن کلاینت بیرون میروند اعمال میشود. برای یک کار اسکرپینگ این مدت بسیار طولانی است: یک نشانی که پاسخ نمیدهد جریان کار را یک دقیقه و نیم نگه میدارد. مقداری در حدود سی ثانیه برای بیشتر صفحهها کافی است.
اگر برای درخواستهای تکتک مدت دیگری لازم است، CancellationTokenSource به کار میرود. مستندات مایکروسافت میگوید این دو سازوکار با هم کار میکنند و کوتاهتر آن دو معتبر است. همان توکن برای گذاشتن سقف روی کل کار هم به کار میآید: وقتی کاربر کار را متوقف کند یا مدت کل از حد بگذرد، همه درخواستهای در انتظار از یک نقطه لغو میشوند.
لازم نیست منطق تلاش دوباره را از صفر بنویسید. بسته NuGet با نام Microsoft.Extensions.Http.Resilience از مایکروسافت یک هندلر تابآوری آماده برای HttpClient میدهد؛ با فراخوانی AddStandardResilienceHandler روی IHttpClientBuilder بهترتیب لایههای محدودکننده نرخ، مهلت کل، تلاش دوباره، قطعکننده مدار و مهلت هر تلاش وارد کار میشوند. راهبرد پیشفرض تلاش دوباره دستبالا سه بار تلاش میکند و از عقبنشینی نمایی با مؤلفه تصادفی بهره میبرد؛ از میان پاسخهایی که رسیدگی میکند 408، 429 و کدهای 500 و بالاتر هستند. شیوه راهاندازی این بسته با کلاینتهای تکی که تزریق وابستگی ندارند هم در راهنمای کاربرد با یک نمونه نشان داده شده است.
اینکه در کدام کد وضعیت تلاش دوباره معنا دارد موضوعی جداست و تصمیم آن نه با کتابخانه بلکه با شماست: 429 و 503 با انتظار درست میشوند، 403 و 407 نه. خواندن سرایند Retry-After، ساختن درست عقبنشینی نمایی و نمونه کامل حلقهای که بر پایه کدها تصمیم میگیرد در کدهای وضعیت HTTP در وب اسکرپینگ آمده؛ آن کد را اینجا تکرار نمیکنیم.
نمونه کامل: کاری که صفحهها را به ترتیب میپیماید
نمونه زیر ده صفحه نخست فهرست را از راه پروکسی میپیماید، شمار درخواستهای همزمان را به دو محدود میکند، پیش از هر درخواست منتظر میماند و نتیجه را در یک فایل JSON مینویسد.
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 گشودنی را محدود میکند و دیگری شمار کارهای همزمانی را که کد شما میسازد. تفاوت مفهومی این دو را در همروندی و موازیسازی بررسی کردهایم.
نمونه یک بازه ثابت از صفحهها را میپیماید. در کار واقعی شمار صفحهها از پیش دانسته نیست، پیوند «بعدی» دنبال میشود و نشانیهای پیمودهشده در یک صف نگه داشته میشوند. این چیدمان را در صفحهبندی در وب اسکرپینگ میسازیم.
کاربردها
- پایش قیمت و موجودی. پیمایش منظم محصولات خودتان و نمونههای مشابه در فهرستهای باز. چیدمان کلی در راهکار پایش قیمت ما آمده است.
- کارهای سازمانی گردآوری داده. تیمهای بانک، بیمه و ERP که با C# کار میکنند داده گردآمده را همین حالا در پایگاه دادهای در سمت .NET مینویسند؛ ماندن در همان راهکار بهجای افزودن زبانی تازه هزینه نگهداری را پایین میآورد. برای کارهای گسترده راهکار استخراج داده ما نقطه آغاز است.
- پیمایش منظم منابع پرشمار. تا بار روی یک نقطه خروج انباشته نشود، پروکسی چرخشی به کار میرود.
- پنل خودتان که نشست میخواهد. اگر در میانه یک نشست واردشده IP تغییر کند، نشست میافتد؛ در چنین کارهایی پروکسی با نشست ثابت برگزیده میشود.
- بررسی محتوای وابسته به منطقه. مقایسه اینکه همان صفحه از کشورهای گوناگون چگونه دیده میشود با تغییر مکان خروج شدنی است.
خطاهای رایج
- ساختن
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 با پروکسی میروید.
خلاصه
بخش دشوار گرفتن داده با C# تجزیه نیست، برپا کردن کلاینت است. یک نمونه HttpClient که تا پایان عمر برنامه زنده بماند، عمر اتصالی که با PooledConnectionLifetime محدود شده و مهلتی در حدود سی ثانیه، کار را در حال رشد سرپا نگه میدارد. پروکسی را در قالب WebProxy به ویژگی Proxy هندلر میدهید و اطلاعات هویت را با NetworkCredential در میدانهای جداگانه نگه میدارید. مرز سرعت را با SemaphoreSlim در کد خودتان میسازید و تلاش دوباره را به یک هندلر تابآوری آماده میسپارید. نگه داشتن تجزیه در تابعی جداگانه هم سبب میشود با تغییر ساختار صفحه تنها یک جا را درست کنید. گونههای پروکسی مناسب کارهای گردآوری دادهتان را در خدمات پروکسی ما مییابید.




