<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building an AI-powered website health checker in .NET ]]></title><description><![CDATA[Building an AI-powered website health checker in .NET ]]></description><link>https://jasdeep.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:44:29 GMT</lastBuildDate><atom:link href="https://jasdeep.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building an AI-powered website health checker in .NET — Day 1: a link checker with 15 green tests]]></title><description><![CDATA[What I learned about IHttpClientFactory, parallel requests and cancellation while building SiteHealth in public]]></description><link>https://jasdeep.hashnode.dev/building-an-ai-powered-website-health-checker-in-net</link><guid isPermaLink="true">https://jasdeep.hashnode.dev/building-an-ai-powered-website-health-checker-in-net</guid><dc:creator><![CDATA[jasdeep singh]]></dc:creator><pubDate>Sun, 25 Oct 2026 05:21:19 GMT</pubDate><content:encoded><![CDATA[<p>I'm a .NET and Optimizely developer, and I've decided to learn system design, Azure and AI engineering the hard way: by building one real project, end to end, in public.</p>
<p>The project is <strong>SiteHealth</strong>, an AI-powered website health monitor. The idea: point it at a website, and it finds broken links, SEO problems, slow pages and accessibility issues, then uses Claude to explain each problem and suggest a fix. Eventually it will run on Azure with queues, workers and a React dashboard, and show results inside the Optimizely CMS editor.</p>
<p>But every big project starts small. Day 1 was a command-line link checker.</p>
<blockquote>
<p>🔗 Code: <a href="https://github.com/jasdeep-singh2/sitehealth">github.com/jasdeep-singh2/sitehealth</a></p>
</blockquote>
<h2>What it does</h2>
<pre><code class="language-text">dotnet run --project src/SiteHealth.Cli -- https://books.toscrape.com/

Fetching https://books.toscrape.com/ ...
Found 73 links. Checking them...

0 broken out of 73 links.
</code></pre>
<p>It works in three steps:</p>
<ol>
<li><strong>PageFetcher</strong> downloads the page's HTML.</li>
<li><strong>LinkExtractor</strong> finds every link on the page.</li>
<li><strong>LinkChecker</strong> checks each link, five at a time, and reports the broken ones.</li>
</ol>
<p>I wrote the tests first and then made them pass one step at a time. By the end of the day: <strong>15 tests, all green.</strong></p>
<p>Here are the three things I learned that I think are worth sharing.</p>
<h2>Lesson 1: never <code>new HttpClient()</code> per request</h2>
<p>This is one of the classic .NET mistakes:</p>
<pre><code class="language-csharp">// ❌ Don't do this
using var client = new HttpClient();
var html = await client.GetStringAsync(url);
</code></pre>
<p>Creating a new <code>HttpClient</code> for every request leaves sockets hanging around after it's disposed. Under load, you can run out of them, which is known as <em>socket exhaustion</em>.</p>
<p>The fix is <code>IHttpClientFactory</code>. I register a <strong>typed client</strong>, and the class simply asks for an <code>HttpClient</code> in its constructor:</p>
<pre><code class="language-csharp">builder.Services.AddHttpClient&lt;PageFetcher&gt;(ConfigureCrawlerClient);
</code></pre>
<pre><code class="language-csharp">public sealed class PageFetcher(HttpClient httpClient)
{
    public async Task&lt;string&gt; FetchHtmlAsync(Uri url, CancellationToken cancellationToken)
    {
        using var response = await httpClient.GetAsync(url, cancellationToken);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(cancellationToken);
    }
}
</code></pre>
<p>The factory reuses connections behind the scenes, and there's a bonus I didn't expect: <strong>testing becomes easy.</strong> Because <code>PageFetcher</code> receives its <code>HttpClient</code> instead of creating it, my tests pass in an <code>HttpClient</code> backed by a fake handler. The tests never touch the real internet, but <code>PageFetcher</code> can't tell the difference.</p>
<pre><code class="language-csharp">var handler = new StubHttpMessageHandler(_ =&gt; new HttpResponseMessage(HttpStatusCode.NotFound));
var fetcher = new PageFetcher(new HttpClient(handler));

await Assert.ThrowsAsync&lt;HttpRequestException&gt;(() =&gt; fetcher.FetchHtmlAsync(url, CancellationToken.None));
</code></pre>
<h2>Lesson 2: check links in parallel, but politely</h2>
<p>Checking 73 links one by one is slow. Checking all 73 at once hammers the website, which is rude and can get you blocked.</p>
<p><code>Parallel.ForEachAsync</code> with a limit gives the best of both:</p>
<pre><code class="language-csharp">var parallelOptions = new ParallelOptions
{
    MaxDegreeOfParallelism = _options.MaxConcurrency, // 5, from appsettings.json
    CancellationToken = cancellationToken,
};

await Parallel.ForEachAsync(links, parallelOptions, async (url, token) =&gt;
{
    results.Add(await CheckOneAsync(url, token));
});
</code></pre>
<p>Two details I learned along the way:</p>
<ul>
<li><code>ConcurrentBag</code> instead of <code>List</code>: five checks run at the same time, and a normal <code>List</code> isn't safe to add to from several threads.</li>
<li><strong>HEAD before GET</strong>: a <code>HEAD</code> request asks "does this page exist?" without downloading it. Some servers refuse <code>HEAD</code> with <em>405 Method Not Allowed</em>, so the checker falls back to <code>GET</code>.</li>
</ul>
<p>This limit is my first real system design concept: <strong>back-pressure</strong>, controlling how much work runs at once so neither side gets overwhelmed. Later in the project, Azure Service Bus and scaled-out workers will do the same job at a bigger scale.</p>
<h2>Lesson 3: a timeout and Ctrl+C throw the same exception</h2>
<p>This one surprised me. When a website is too slow, <code>HttpClient</code> throws a <code>TaskCanceledException</code>. When the user presses Ctrl+C and the cancellation token fires, it <em>also</em> throws a <code>TaskCanceledException</code>.</p>
<p>But they need different handling:</p>
<ul>
<li><strong>Timeout:</strong> the site is slow, so record the link as broken and carry on.</li>
<li><strong>Ctrl+C:</strong> the user wants to stop, so actually stop.</li>
</ul>
<p>An exception filter tells them apart:</p>
<pre><code class="language-csharp">catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
    // Our token wasn't cancelled, so this was a timeout
    return new LinkCheckResult(url, null, "Timed out");
}
</code></pre>
<p>If the user cancelled, the filter doesn't match, and the exception bubbles up and stops the program, which is exactly what we want.</p>
<h2>How I'm using AI to learn (not to skip learning)</h2>
<p>I'm using Claude as a tutor rather than a code generator. It set up the project skeleton and the tests, then gave me fill-in-the-blanks exercises for each step. The tests defined when I was "done," and I typed and debugged every implementation myself.</p>
<p>That balance has worked well: I moved fast, but I understand every line I committed.</p>
<h2>What's next</h2>
<p><strong>Step 4:</strong> send the broken links to Claude and get back a plain-English explanation and a suggested fix for each one, as structured JSON. That will be my first real AI integration in .NET.</p>
<p>After that: moving the checker into a background worker, then onto Azure with a queue.</p>
<p>If you're a .NET or Optimizely developer learning AI or Azure too, I'd love to hear what you're building. The code is all on <a href="https://github.com/jasdeep-singh2/sitehealth">GitHub</a></p>
]]></content:encoded></item></channel></rss>