This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Friday, 19 December 2025
Nota di sintesi: Questo articolo insegna i fondamenti dell'estrazione dei contenuti web con LLM utilizzando l'approccio più semplice possibile. Per i casi di utilizzo della produzione (riepilogazione web, analisi dei documenti, strumenti di agente), vedere DocSummarizer
- implementa l'architettura qui mostrata ma la produzione-grado: BERT embeddings, ricerca ibrida, Playwright per SPA, protezione SSRF, citazioni convalidate, e corretto monitoraggio della copertura.
Quando chiedi a ChatGPT di "leggere questo articolo e riassumerlo," cosa succede realmente? Se immagini l'intelligenza artificiale aprire un browser e leggere come si farebbe - non è così che funziona.
Gli LLM non navigano nel web. Ragionano su frammenti selezionati dal vostro codice.
Questo articolo ti mostra come costruire questo in C# con Ollama - nessun framework, solo codice pratico che puoi debug.
TL;DR: Il vostro codice prende → pulisce → pezzi → seleziona. LLM vede solo i frammenti che si iniettano. La selezione è lossy dal design - che è sia il vincolo che l'architettura.
Ecco l'intuizione che cambia il modo in cui si costruiscono questi sistemi:
La selezione è la decisione del prodotto. E 'anche dove la maggior parte dei fallimenti hanno origine.
Il LLM è a valle della vostra selezione. Non può recuperare informazioni che non avete mostrato. Quando un agente "fails per trovare la risposta," il problema non è quasi mai il modello - è che la vostra logica di selezione ha scelto i pezzi sbagliati. (Questo è lo stesso principio dietro perché evito quadri come LangChain - astrattano la logica di selezione di cui hai bisogno per il debug.)
Questo è il motivo per cui "agente di navigazione" fallisce in silenzio. L'agente risponde fiduciosamente in base a ciò che ha visto. Non si sa mai che ha perso il contenuto giusto.
flowchart LR
URL[Full Page] --> Select[Your Selection]
Select --> LLM[LLM Sees This]
LLM --> Answer[Answer]
URL -.->|"50KB"| Select
Select -.->|"2KB"| LLM
Miss[Missed Content] -.->|"Never seen"| X[❌]
style Select stroke:#e74c3c,stroke-width:3px
style Miss stroke:#95a5a6,stroke-width:2px,stroke-dasharray: 5 5
Il modello può solo ragionare su ciò che gli hai dato. Costruisci di conseguenza.
Prima di scrivere qualcosa, questi sono i vincoli:
| Velocità | Perché ha importanza |
|---|---|
| Nessun rendering JS | Solo HTML statico (Playwright per SPA) |
| Token bounded | Hard budget per richiesta (2-4K token tipici) |
| Risposte legate alla fonte | LLM non deve allucinare la conoscenza del web |
| Selezione deterministica | Stesso ingresso → stesso pezzo (debuggabile) |
| Osservabile | Log what you select, why, and what you widget |
Se non riesci a spiegare perché è stato selezionato un pezzo, non puoi eseguire il debug dei guasti.
flowchart TB
URL[URL] --> Fetch[1. Fetch]
Fetch --> Clean[2. Clean]
Clean --> Chunk[3. Chunk]
Chunk --> Select[4. Select]
Select --> LLM[LLM]
LLM --> Answer[Answer]
Fetch -.->|"57KB HTML"| Clean
Clean -.->|"6KB text"| Chunk
Chunk -.->|"5 chunks"| Select
Select -.->|"2 chunks"| LLM
style Select stroke:#e74c3c,stroke-width:3px
style Clean stroke:#f39c12,stroke-width:3px
Ogni passo riduce i dati. Quando l'LLM lo vede, sei passato da 57KB di HTML a forse 2KB di testo rilevante. Ogni riduzione è lossy. Ogni riduzione può scartare la risposta. Questo è lo stesso modello che uso per analisi di grandi file CSV - le ragioni LLM, il vostro codice calcola e seleziona.
In tutto questo articolo, useremo un URL:
https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/overview
E tre domande di maggiore specificità:
Questo mantiene gli esempi di base e mostra come la selezione conta di più come le domande diventano specifiche.
# Install Ollama from https://ollama.ai
ollama pull llama3.2:3b
# NuGet packages
dotnet add package AngleSharp # HTML parsing
dotnet add package OllamaSharp # Ollama client (5.1.x)
Nota di OllamaSharp: Nella versione 5.x,
GenerateAsyncritornaIAsyncEnumerable<GenerateResponseStream?>Si accumulano con...await foreach. I perni del progetto campione 5.1.5.
Standard HTTP, ma con i dettagli che contano:
public class WebFetcher : IDisposable
{
private readonly HttpClient _http;
public WebFetcher()
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5, // Cap redirects
AutomaticDecompression = DecompressionMethods.All
};
_http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
_http.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
}
public async Task<string> FetchAsync(string url)
{
var response = await _http.GetAsync(url);
// Bail if not HTML
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.Contains("html") && !contentType.Contains("text"))
throw new InvalidOperationException($"Not HTML: {contentType}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public void Dispose() => _http.Dispose();
}
Che cosa questo maneggia: Redirects (capped), compressione, timeout, User-Agent, convalida del tipo di contenuto.
Quello che non fa: Rendering JavaScript, autenticazione, limitazione dei tassi, robots.txt. Per la produzione, aggiungere ritardi per host e rispettare le politiche di crawl.
L'HTML grezzo è per lo più rumore. Puliscilo o rifiuti gettoni sul layout.
Una pagina tipica:
57KB HTML → 6KB useful text (90% reduction)
flowchart LR
HTML[Raw HTML] --> Remove[Remove Noise]
Remove --> Find[Find Main Content]
Find --> Extract[Extract Text + Headings]
Extract --> Normalize[Normalize]
Remove -.->|"script, style, nav, ads"| Find
Find -.->|"main, article, .content"| Extract
style Remove stroke:#e74c3c,stroke-width:3px
style Find stroke:#f39c12,stroke-width:3px
Il vostro pulitore può assolutamente eliminare la risposta.
.article-body, .post-text)Contenziosi:
h1-h6)public class HtmlCleaner
{
private readonly HtmlParser _parser = new();
// Known noise - remove these entirely
private static readonly string[] NoiseElements =
{ "script", "style", "nav", "footer", "aside", "iframe", "noscript" };
// Boilerplate patterns - remove by role, not aggressive wildcards
private static readonly string[] NoiseSelectors =
{
"[role='navigation']", "[role='banner']", "[role='complementary']",
"[class*='cookie']", "[class*='newsletter']", "[aria-hidden='true']"
};
// Where to find content - order matters (most specific first)
private static readonly string[] ContentSelectors =
{ "main", "article", "[role='main']", ".content", ".post-content" };
public CleanResult Clean(string html)
{
var doc = _parser.ParseDocument(html);
// Remove noise
foreach (var tag in NoiseElements)
foreach (var el in doc.QuerySelectorAll(tag).ToList())
el.Remove();
foreach (var selector in NoiseSelectors)
foreach (var el in doc.QuerySelectorAll(selector).ToList())
el.Remove();
// Find main content
IElement? main = null;
string? matchedSelector = null;
foreach (var selector in ContentSelectors)
{
main = doc.QuerySelector(selector);
if (main != null) { matchedSelector = selector; break; }
}
// Fallback to body if main content is suspiciously short
var text = main?.TextContent ?? "";
if (text.Length < 500 && doc.Body != null)
{
main = doc.Body;
matchedSelector = "body (fallback)";
text = main.TextContent;
}
return new CleanResult
{
Text = NormalizeWhitespace(text),
MatchedSelector = matchedSelector ?? "none",
OriginalLength = html.Length
};
}
private string NormalizeWhitespace(string text)
{
text = Regex.Replace(text, @"[ \t]+", " ");
text = Regex.Replace(text, @"\n\s*\n+", "\n\n");
return text.Trim();
}
}
public record CleanResult(string Text, string MatchedSelector, int OriginalLength);
Estrazione di leggibilità Per ora, l'estrazione basata sul selettore funziona per la documentazione e i blog. Il progetto di esempio include un estrattore di punteggio se ne hai bisogno.
Hai 6KB di testo pulito, perche' non mandare tutto?
La strategia di "Chunking" conta piu' di quanto ci si possa aspettare. Articolo sull'architettura RAG - gli stessi principi si applicano sia che tu stia scrivendo pagine web o documenti.
Questo è un punto di partenza, non un codice di produzione:
public List<string> ChunkBySentence(string text, int maxTokens = 2000)
{
var chunks = new List<string>();
// WARNING: This breaks on abbreviations, decimals, URLs, code samples
var sentences = text.Split(new[] { ". ", ".\n", "! ", "? " },
StringSplitOptions.RemoveEmptyEntries);
var current = new StringBuilder();
var tokens = 0;
foreach (var sentence in sentences)
{
var sentenceTokens = EstimateTokens(sentence);
if (tokens + sentenceTokens > maxTokens && current.Length > 0)
{
chunks.Add(current.ToString().Trim());
current.Clear();
tokens = 0;
}
current.Append(sentence).Append(". ");
tokens += sentenceTokens;
}
if (current.Length > 0)
chunks.Add(current.ToString().Trim());
return chunks;
}
// Rough estimate - OK for demos, not for billing
private int EstimateTokens(string text)
=> (int)(text.Split(' ').Length * 1.3);
Perché questo è ingenuo:
". " rompe su "Dr. Smith," "v1.0," URLPer la documentazione, pezzo per sezione:
public List<ContentChunk> ChunkByHeadings(string html)
{
var doc = new HtmlParser().ParseDocument(html);
var chunks = new List<ContentChunk>();
var headings = doc.QuerySelectorAll("h1, h2, h3");
foreach (var heading in headings)
{
var content = new StringBuilder();
content.AppendLine(heading.TextContent);
var sibling = heading.NextElementSibling;
while (sibling != null && !sibling.TagName.StartsWith("H"))
{
content.AppendLine(sibling.TextContent);
sibling = sibling.NextElementSibling;
}
chunks.Add(new ContentChunk
{
Heading = heading.TextContent.Trim(),
Content = content.ToString().Trim(),
HeadingLevel = int.Parse(heading.TagName[1..])
});
}
return chunks;
}
Ciò preserva la struttura dei documenti e rende la selezione più significativa.
Questo è il punto in cui la maggior parte dei fallimenti accadono e dove il debugging dovrebbe iniziare.
Avete 5 pezzi. L'utente ha chiesto "Quali miglioramenti di prestazioni sono in .NET 10?" Solo 1-2 pezzi menzionano le prestazioni. Inviarli.
flowchart TB
Q["Question: What perf improvements?"] --> Score[Score Each Chunk]
subgraph Chunks
C1["Chunk 1: Overview..."]
C2["Chunk 2: Runtime perf..."]
C3["Chunk 3: Libraries..."]
C4["Chunk 4: SDK changes..."]
end
Score --> C1
Score --> C2
Score --> C3
Score --> C4
C2 -->|"score: 3"| Top[Selected]
C3 -->|"score: 1"| Top
style C2 stroke:#27ae60,stroke-width:3px
style C1 stroke:#95a5a6,stroke-width:2px,stroke-dasharray: 5 5
style C4 stroke:#95a5a6,stroke-width:2px,stroke-dasharray: 5 5
public record ScoredChunk(string Content, string? Heading, int Score, List<string> MatchedKeywords);
public List<ScoredChunk> SelectByKeywords(
List<ContentChunk> chunks,
string question,
int topK = 3)
{
// Normalize and filter stopwords
var keywords = question.ToLower()
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 3)
.Where(w => !Stopwords.Contains(w))
.Select(w => w.Trim(',', '.', '?', '!'))
.Distinct()
.ToList();
var scored = chunks.Select(chunk =>
{
var text = (chunk.Heading + " " + chunk.Content).ToLower();
var matched = keywords.Where(kw => text.Contains(kw)).ToList();
// Boost if keyword appears in heading
var headingBoost = chunk.Heading != null &&
keywords.Any(kw => chunk.Heading.ToLower().Contains(kw)) ? 2 : 0;
return new ScoredChunk(
chunk.Content,
chunk.Heading,
matched.Count + headingBoost,
matched
);
})
.OrderByDescending(x => x.Score)
.Take(topK)
.ToList();
// LOG THIS - it's your debugging lifeline
foreach (var s in scored)
Console.WriteLine($" [{s.Score}] {s.Heading ?? "(no heading)"}: {string.Join(", ", s.MatchedKeywords)}");
return scored;
}
private static readonly HashSet<string> Stopwords = new()
{ "what", "how", "does", "the", "are", "is", "in", "for", "of", "to", "and" };
Principali miglioramenti rispetto al conteggio ingenuo:
Parole chiave falliscono sui sinonimi. "perf" non corrisponde a "miglioramento delle prestazioni."
Le inserzioni trovano somiglianza semantica. Se si vuole andare più a fondo sulle inserzioni e la ricerca vettoriale, lo copro ampiamente nel Serie primer RAG e ricerca semantica con ONNX.
public async Task<List<ScoredChunk>> SelectByEmbedding(
List<ContentChunk> chunks,
string question,
int topK = 3)
{
var questionEmbed = await EmbedAsync(question);
// Cache these per URL in production
var scored = new List<(ContentChunk Chunk, double Score)>();
foreach (var chunk in chunks)
{
var chunkEmbed = await EmbedAsync(chunk.Content);
var similarity = CosineSimilarity(questionEmbed, chunkEmbed);
scored.Add((chunk, similarity));
}
return scored
.OrderByDescending(x => x.Score)
.Take(topK)
.Select(x => new ScoredChunk(x.Chunk.Content, x.Chunk.Heading, (int)(x.Score * 100), new()))
.ToList();
}
private async Task<double[]> EmbedAsync(string text)
{
var request = new EmbedRequest { Model = "nomic-embed-text", Input = [text] };
var response = await _ollama.EmbedAsync(request);
return response.Embeddings.First().ToArray();
}
Trade-off:
Per la produzione, embeddings cache per (URL, hash chunk) in SQLite o un database vettoriale come QdrantCity name (optional, probably does not need a translation).
Strutturare il prompt per forzare le risposte source-bounded con citazione:
public string BuildPrompt(string url, List<ScoredChunk> chunks, string question)
{
var sb = new StringBuilder();
sb.AppendLine("You are answering a question using ONLY the content below.");
sb.AppendLine("Rules:");
sb.AppendLine("- Answer ONLY from the provided sources");
sb.AppendLine("- Cite which SOURCE number supports each claim");
sb.AppendLine("- Include 1-2 brief quotes as evidence");
sb.AppendLine("- If the answer isn't in the sources, say 'Not enough information'");
sb.AppendLine("- End with Confidence: High/Medium/Low");
sb.AppendLine();
for (int i = 0; i < chunks.Count; i++)
{
sb.AppendLine($"=== SOURCE {i + 1} ===");
if (chunks[i].Heading != null)
sb.AppendLine($"Section: {chunks[i].Heading}");
sb.AppendLine($"From: {url}");
sb.AppendLine(chunks[i].Content);
sb.AppendLine();
}
sb.AppendLine($"Question: {question}");
sb.AppendLine();
sb.AppendLine("Answer (with citations and confidence):");
return sb.ToString();
}
Questo passa da "sintesi di chiacchierata" a "analisi con provenienza."
public async Task<string> AskAsync(string prompt)
{
var request = new GenerateRequest { Model = "llama3.2:3b", Prompt = prompt };
var response = new StringBuilder();
await foreach (var chunk in _ollama.GenerateAsync(request))
{
if (chunk?.Response != null)
response.Append(chunk.Response);
}
return response.ToString().Trim();
}
Quello che abbiamo costruito e' un modello di agente senza il framework. Perché preferisco questo approccio a LangChain - l'orchestrazione esplicita batte le astrazioni magiche quando il debug conta.
flowchart LR
subgraph Tools["Tools (Deterministic)"]
T1[fetch_url]
T2[clean_html]
T3[chunk_text]
T4[select_relevant]
end
subgraph LLM["LLM (Reasoning)"]
R[Interpret + Answer]
end
T1 --> T2 --> T3 --> T4 --> R
R -->|"Low confidence"| Retry[Retry with different selection]
Retry --> T4
style T4 stroke:#e74c3c,stroke-width:3px
style R stroke:#3498db,stroke-width:3px
Il ciclo: se la fiducia è bassa, riprovare con più pezzi o parole chiave diverse.
var answer = await AskAsync(prompt);
if (answer.Contains("Not enough information") || answer.Contains("Confidence: Low"))
{
// Retry with more chunks
var moreChunks = SelectByKeywords(allChunks, question, topK: 5);
answer = await AskAsync(BuildPrompt(url, moreChunks, question));
}
flowchart TB
subgraph Failures["Failure Modes"]
F1[Cleaner removes content]
F2[Chunking breaks mid-thought]
F3[Selection picks wrong chunks]
F4[LLM hallucinates connections]
end
F1 --> R1["'Not enough info' - answer existed"]
F2 --> R2["Partial answer - context lost"]
F3 --> R3["Wrong answer - right content skipped"]
F4 --> R4["Confident but wrong"]
style F3 stroke:#e74c3c,stroke-width:3px
La regola di debug:
Se la risposta è sbagliata, è quasi sempre perché selezione errata, non perché il modello ha fallito.
Di solito il fallimento non e' l'LLM, e' a monte.
public class WebAnalyzer : IDisposable
{
private readonly WebFetcher _fetcher = new();
private readonly HtmlCleaner _cleaner = new();
private readonly OllamaApiClient _ollama = new(new Uri("http://localhost:11434"));
public async Task<AnalysisResult> AnalyzeAsync(string url, string question)
{
// 1. Fetch
var html = await _fetcher.FetchAsync(url);
// 2. Clean (with observability)
var cleaned = _cleaner.Clean(html);
Console.WriteLine($"Cleaned: {cleaned.OriginalLength} → {cleaned.Text.Length} bytes ({cleaned.MatchedSelector})");
// 3. Chunk
var chunks = ChunkByHeadings(html);
Console.WriteLine($"Chunks: {chunks.Count}");
// 4. Select (with logging)
Console.WriteLine("Selection scores:");
var selected = SelectByKeywords(chunks, question, topK: 3);
// 5. Prompt + LLM
var prompt = BuildPrompt(url, selected, question);
var answer = await AskAsync(prompt);
return new AnalysisResult
{
Answer = answer,
ChunksUsed = selected.Count,
SelectionScores = selected.Select(s => s.Score).ToList()
};
}
public void Dispose() => _fetcher.Dispose();
}
Utilizzo con il nostro esempio corrente:
using var analyzer = new WebAnalyzer();
var result = await analyzer.AnalyzeAsync(
"https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/overview",
"What performance improvements are in .NET 10?"
);
Console.WriteLine(result.Answer);
L'output include citazioni e fiducia:
Based on SOURCE 1 and SOURCE 2:
.NET 10 includes several performance improvements:
- JIT improvements including better inlining and method devirtualization (SOURCE 1)
- "Enhanced loop inversion for better optimization" (SOURCE 1)
- NativeAOT enhancements for improved code generation (SOURCE 2)
Confidence: High
| Funziona bene | Non funziona |
|---|---|
| Documentazione | JavaScript SPAs |
| Post di blog, articoli | Contenuti dinamici/interattivi |
| Riferimenti tecnici | Ricerca su più pagine |
| HTML statico | Contenuto autenticato |
Per i siti JS-heavy, è necessario Playwright per .NET.
flowchart LR
subgraph Your["Your Code's Job"]
direction TB
F[Fetch reliably]
C[Clean carefully]
S[Select correctly]
O[Observe everything]
end
subgraph LLM["LLM's Job"]
direction TB
R[Reason over what you gave it]
A[Admit when it doesn't know]
end
Your --> LLM
style S stroke:#e74c3c,stroke-width:3px
style R stroke:#3498db,stroke-width:3px
L'LLM può solo ragionare su ciò che gli hai dato. La selezione è una tua responsabilità.
Non chiedere all'LLM di navigare. Chiedigli di ragionare.
Completa attuazione del lavoro: Per lo più Lucid.LlmWebFetcher
Comprende:
WebFetcher - HTTP con una corretta gestioneHtmlCleaner - Rimozione del rumore + strategie di ripiegoContentChunker - Condanna e intestazioneWebContentAnalyzer - Conduttura completa con registrazioneOllamaExtensions - Aiuto per le risposte in streamingcd Mostlylucid.LlmWebFetcher
dotnet run
Biblioteche
LLMsCity name (optional, probably does not need a translation)
Articoli correlati
Microsoft AI Stack
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.