# StyloBot Release Series: Local Markdown Mode

*Cloudflare's "Modus MarkdownM SK2 dà ai crawler AI un testo pulito invece dell'HTML rappresentato . Gli operatori del StyloBot ricevono la stessa capacità attraverso il StyloExtractMSC4 che funziona localmente sulla porta dello StylobotMska5 abbastanza veloce da stare sul percorso caldoMske6 senza cambiamenti necessari al sito in alto flussoM Ske7 Questo è quello che fa Styloextract*, come si collega al Stylo Bot*, cosa cattura il cibo per cani tramite lucidVIEWMsek10 e cosaMsko11 è ancora rottaMске12*

<!--category-- Architecture, StyloBot, StyloExtract, Markdown, lucidVIEW -->
---


## Quello che volevo copiare

Cloudflare invia una funzione dove, se un crawler artificiale riconosciuto chiede una pagina, il bordo fornisce una rappresentazione Markdown del corpo invece dell'HTML completoM SK2 Il crawler riceve il contenuto reale senza nessun cromo , nessun JavaScriptMSC4 nessuno dei banner cookieMST5 L'utenteMst6 il sito in visita rimane lo stessoMSST7 il crawlerMsST8 la vita diventa più sempliceMSS9 Cloud Flare viene pagato per questoMSR10 Tutti vinconoMRS11

L'architettura è ovvia dall'esterno: classificare la richiesta come un crawler (Cloudflare lo fa già), trascinare la pagina attraverso qualche contenutoM SK3layere di estrazioneMSC4 portare il progetto a MarkdownMNK5 restituire che invece del HTMLMRK6 Il classificatore è la parte interessante se sei Cloudflare e l'extratore è quella interessante se sia qualcuno altroMMK9

La stessa capacità per un operatore StyloBot ha tre limiti Cloudflare non't:

1. **Il sito è in arrivo alla porta dello StyloBot.** Cloudflare può servire alla marcazione perché sono l'estremità. Una porta dello StyloBot si trova davanti alle origini superiori non è controllataM SK1, approcciando con YARPMSC3 Il contenuto deve essere estratto sulla rampa della porta , non all'origine e non in qualche SaaS roundMST5tripMst6 Il sito superiore rimane intattoM st7
2. **Locale. Niente Python, nessun NodeM SK2 nessun servizio separato .** Il gateway è un singolo AOT-published binary. L'aggiunta di una Python sidecar per HTML a Markdown sconfigge la forma di deploymentM SK2
3. **Molto veloce per stare sul percorso caldo.** Se "serve Markdown ai crawler dell'AI" aggiunisce 200ms per richiestaM SK3 l'operatore lo spegneMSC4 Il budget è inferiore a -15ms pMST6 su un battito di cacheMS, che è quello in cui vive il middleware di rilevamento esistente dentroMSSK8

Il lato dello StyloBot di questo è già stato fatto: il tubo del rilevatore classifica i crawler AI (`BotType.AiBot`) con la stessa rapidità di latenza del percorso - rispetto ad ogni altro verdetto `DetectionPolicyMiddleware` in 7.x spedizioni chiamate politiche di azione per giudizio, così connettendo un `extract-markdown` Politiche per "AiBot /docs/*" è una linea di configurazioneM SK1 Il pezzo mancante era l'extratore stesso.

Che's StyloExtract: l'HTML -toM SK3Layere Markdown che si mette in paragone con il StyloBotMSC4s detectionMNK5 L'integrazione è un piccolo pacco di adapter MRK6`Mostlylucid.BotDetection.StyloExtract`) e la regola che lo attiva sono sei linee di JSON. Un operatore lascia cadere il pacco inM SK2 aggiunge la regola , e i crawler dell'AI iniziano a ricevere il Markdown per le stesse URL che ancora servono HTML agli esseri umani

## Cosa significa "estratto Markdown"?

Se leggete lo spezzone a valore nominale, M SK1convertete HTML in Markdown" è un problema risolto . Ci sono dozzine di bibliotecheMSC4 La realtà è che si degradano tutte sulle pagine reali perché prendono il documento come contenuto piatto e la pagina come scopingMST5

Le pagine web del mondo sono 80-95% boilerplate. L'articolo che volete vive dentro due o tre coperte. `<div>`s di due o trecento. Una HTML naiva-aM SK2La biblioteca Markdown convertisce tutto : ogni link navMSC4 ogni frase di banner cookieMST5 ogni piedeMst6 ogni collegatoM st7 la barra laterale postMstr8 ogni bottone di condivisioneMSt9 ogni modalità nascosta dal CSS ma presente nel DOMM ST11 Il crawler ha chiesto l'articoloMSST12 l'ha dato un intero MsST13KB Markdown file con l' articolo sepolto da qualche parte in linea MSST14

Quindi l'extratore deve fare due lavori:

1. **Identificare il corpo.** Quale sottotrema del DOM analizzato contiene il contenuto reale, e quali sottotremi sono di cromo?
2. **Rendere il corpo.** Una volta isolato, ',Progetta quel sottoalbero alla Markdown che conserva la struttura un lettore in avalito (umano o LLM) si preoccupa della gerarchia delle sequenze di heading:Headership,Links inline,Liste,Tabelle,Blocki di codice,Image.

L'identificazione è la più difficile. *Extrazione di contenuti web* E il punto di riferimento stabilito è WCXB. Lo stato d'arte quando ho iniziato a leggere era uno strumento Python chiamato Trafilatura. Ho trasferito la carica M SK2carne delle idee al .NET e ho aggiunto una strata Traflatura non haMSC4t haveMスク5 per SSK6host *L'apprendimento dei modelli.* attraverso l'impronta digitale strutturale.

La forma è:

```
parse HTML → clean → fingerprint the structure → does this fingerprint
  match a template I've seen on this host before?
    yes → apply the cached extractor → render
    no  → run the heuristic classifier → induce a fresh extractor →
          cache it under this host's fingerprint → render
```

La prima volta che il gateway vede una pagina da un host, paga l'euritica completa-costa di classificazione M SK2μs per una pagina mediaMSC3 Ogni pagina successiva da quel host, la cui impronta digitale strutturale corrisponde, riceve invece l'extratore cached , che è una manciata di CSSMST5questioni di selettore MST6μsM ST7 Le pagine da host con lo stesso modello SST9 che é la maggior parte del sito SST10 passano attraverso il percorso veloceS ST11 pagine con nuovi schemi provocano una riconsiderazioneS st12

L'impronta digitale è uno schizzo di MinHash su una rappresentazione normalizzata di DOM-pace, con un bander LSH in cima per la sonda sub--millisecondiM SK3 La matematica della corrispondenza non importa 'non importa per questo articoloMSC5 quello che importa è che rende l'apprendimento per ogni host abbastanza economico da funzionare su ogni richiestaMSL7

## Il passeggero

Il lato di render è dove il circuito di dogfood mi ha fatto guadagnare.

La prima versione di StyloExtract emette Markdown camminando i blocchi classificati e proiettando. `element.TextContent.Trim()` in output. Ogni blocco diventò un paragrafo `# text` a prescindere da se fosse H1 o H 4. Le ancorhe sono crollate in un testo nudo. Le liste hanno perso le loro bolleM SK3 I blocchi di codici hanno perso i loro recidiMSC4 Le tabelle sono state piastrellate con una commaMST5L'interazione separataMSV6

Questo è tecnicamente "Markdown." E' anche inutileM SK2 . Il crawler artificiale chiede Markdown a *evitare di analizzare il cromo.*, e gli diamo una parete di paragrafi dove l'unico segnale è occasionalmente semplice -la frase dei nomi di testo . L'intero punto del Markdown è che la struttura porta informazioni M SK3 tagliare la struttura e voi MSC4 avete appena dato al crawler un sacchetto leggermente più piccolo di stringhe

Quindi la prossima versione di StyloExtract fa camminare il DOM di ogni blocco di contenuto classificato e emette un reale GFM:

- `<h1>` attraverso. `<h6>` diventano 1 attraverso 6 `#` Character
- `<a href>` diventerà `[text](href)`
- `<em>` e `<strong>` Continuare a mettere l'accento.
- `<code>` rimane bloccato, `<pre>` diventa un blocco protetto con `language-x` Portata in giro
- `<ul>` e `<ol>` rilascia bullet reali e liste numerate.
- `<blockquote>` Prefixi ogni linea con `> ` ( e la citazione multiM SK1 del paragrafo ottiene il `> body\n>\n> body` convention)
- `<table>` viene ricostruita attraverso un slot WHATWG-algoritmo di griglia con un colspanne appropriato / rowspan / captioning | / manipolazione dell'alignamento |, ritornando a HTML crudo quando la fonte è troppo complessa per che il GFM possa esprimere |
- Le immagini ritraggono come `![alt](src)` Che siano inline nella prosa o in standalone. `<figure>`

Il walkinger è un StringBuilder per blocco, un buffere interno riutilizzato attraverso le cellule / elementi di listaM SK2 e un singolo passaggio oltre il DOMMSC3 Dopo l'utilizzazione (il primo taglio era 304μs M/ m191KB per una tavolaMNK8 pagina pesanteMMK9 si trova a R~70μS P/ \165KB nel peggiore casoMRK13 La quota di rendere del tutto il tubo di extrazione è passata da \25-55% a \M5-11%.

La camminata è anche la parte che si è rotta prima.

## La catena di cibo per cani

lucidVIEW è un Viewer Markdown che ho costruito insieme a StyloExtract. Il prodotto è un browserM SK1 un lettore laterale che prende una URL, lo porta fuoriMSC3 fa passare StyloEstract sulla rispostaMST4 e mostra il risultato MarkdownMSSK5 Il caso di uso è " Voglio leggere questo post sul blog su una connessione lenta senza JavaScriptMSL7 o su un telefono senza l'inventario di pubblicitàMSR8 o solo perché la pagina originale ha un'occhiataM SR9 ha un CSS coloratoMsl10 L'implementazione è StyloEXtract sul serverMSS11 un piccolo renderere Markdown nel browser MSR12 e niente altroMRS13

Quello che lucidVIEW mi ha dato è stato un circuito di dogfooding con denti. L'altro articolo della StyloBot release-seria parla del pannello come della superficie di dog foodingM SK2 che ' è vero alla strata protocollareMSC4 Ma il pannello legge i dati che il motore di rilevamento produce e li rende *Io.*. IM SK1m in ciclismo. I+'perdonerò un sacco di stranezze -prodotto che assomiglia finché i contatori sottostanti sono giusti

lucidVIEW rende Markdown a un lettore in avalito (me, ma leggendo il contenutoM SK2 Ha la stessa forma del caso di uso dell'AI crawler : qualcuno consuma il mio output come *Materiale*, non come strumentoM SK1 Quando il risultato è sbagliato, lo vedete immediatamenteMNK3 C'èMRK4a nessun strato di astrazione tra "l'extratore ha prodotto XMMK6 e " Sto leggendo XSNK8

Perciò apro lucidVIEW puntando al mio blog index. Le carte ritraggono . Poi ruoto . Ogni blogM SK3 link delle carte oltre il primo è ritragnato come `[Post title](/blog/post)` - il testo literale dell'assegnatura - invece di un link cliccabile stilizzato

L'unità-test suite è verde. La fissazione I 'd scritta per M SK3ancor dentro HTML inlineatoMST4 passaMSSK5 Il risultato contiene la stringa `[Post title](/blog/post)`. Il Markdown è comunque sbagliato.

## La classe di errori non avrebbe preso alcun test.

L'indice del blog è costruito con Tailwind. Il risultato di tailwind è indentato per essere leggibile: ogni wrapper div si trova a due spazi più in là dei suoi parentiM SK2 Quando arrivate all'ancora reale per un blog

```html
<section class="...">
    <div class="container...">
        <div class="grid...">
            <div class="card...">
                <a href="/blog/post-a">Post title</a>
                ...
```

Il passeggero' il testo- l'aiuto al manipolatoreM SK2 `AppendEscapedInline`, collassa i passaggi di spazio bianco in uno spazio singolo. All'interno di una chiamata lo fa correttamenteM SK2 la funzione mantiene un `prevWs` bandiera che soppressa spazi bianchi successivi emette. Tra le chiamate, la bandiera si ristabilisceM SK2 Così quando il passeggero visita un testo successivo -nodeMST4diM ST5siblingi di spazio bianco MS ST6 che è esattamente quello che produce l'HTML indentato tra elementi inline vuoti MST7 ogni chiamata emette uno spazio principale crollato sulla lineaMst8startM st9

Quattro impacchettoni di indentazione, quattro testiM SK1nodo-simbi spazi bianchiMSC3quattro spazi collassati .CommonMarkMST5s indentatoMSP6codèMSV7la regola del blocco spara a quattro spaziMSS8Il passeggeroMSSS9s `[Post title](/blog/post)` atterra su una linea che inizia con quattro spazi. Markdig analizza l'intera linea come un blocco di codici. Il testo del bracket si presenta come un testo letterale . Il link è mortoM SK3

Il test dell'unità passa perché il test sostiene la stringa. `[Post title](/blog/post)` appariva da qualche parte nel risultato. lo fa. Lo fa ' è proprio dentro un blocco di codice oraM SK3

La soluzione è due linee: alla linea-startM SK2 primo `prevWs = true` Quindi *in primo luogo* Spazio bianco guidato da ogni nodo di testo viene trascurato invece che emesso. Spazio branco guidato all'interno del HTML indentato non raggiunge mai il risultato. InternaM SK2il paragrafo continua a crollare per spazi singole .

La cosa interessante è quello che il test ha fatto. *Dovrebbe* hanno affermato. Surface-testo `.Contains()` controlla il *Character* Sono presenti. Erano. Eravano solo nel tipo sbagliato di blocco CommonMarkM SK2 Quello che avevo bisogno era un *Structurale* assertion: analizzare il camminatoreM SK1 la fuoriuscita con un vero parsatore CommonMark e affermare che i paragrafi sono paragrafi , i collegamenti sono link, non ci sono blocchi di codice spuriosiMSC4 MarkdigMNK5 l'AST ti dà questo gratisMRK6 Quindi il riparatore di errore è atterrato con un test di regressioneMسک7 e il test di regressione è aterratto con una leva lintaMスク8 un piccolo assistente che prende ogni output del camminatore

- Blocchi di codice indentati a zero a meno che il blocco protetto non sia la fonte.
- Ogni link nella fonte HTML sopravvive come un vero. `LinkInline` nell'AST
- Il numero di blocchi fenced- corrisponde all'aspettazione dichiarata.

Per dimostrare che il lint aveva dei denti, Ho ripreso l'originale riparazione e ho fatto funzionare la suite del lint. Esatto un test fallì - quello che usa i quattroMSC3nestedM SK4forma di scarafaggioMST5 Ristorere la riparazioneMSM6 tutto andato beneMSSK7

Quella singola verifica ora corrisponde a tutti i test dei passeggeri nella suite. Le future regressioni della stessa classe vengono catturate strutturalmente, non con il corrispondenzamento delle stringheM SK2 Il che significa che io ' li troveremo in CI invece di lucidVIEWMSC4

![mostlylucid.net rendered in lucidVIEW after the v1.7.1 walker fix : every blogM SK3 post card resolves as a real clickable link with its summary as a proper paragraphMST4 instead of bracketMst5text inside a code blockM st6](lucidview-mostlylucid.png)

## Che cosa richiede la dogfooding?

C'è una fase in ogni prodotto dove i test delle unità sono verdi e la dimostrazione funziona, l'operatore ha fissato le giuste bandiere di configurazione e si spedisce una versione e un giorno dopo un utente apre un problema dicendo " questo non funziona' non funziona sul contenuto reale "M SK3" Ogni prodotto colpisce questo . La risposta onesta è che le attrezzature per il test e il contenuto della dimostrazione sono sterili in un modo in cui il contenuto reale non lo è.

Il circuito di dogfooding è il modo più economico per uscire da questa trappola. La frase MSC1dogfooding" viene usata liberamenteM SK3 in questo caso vuol dire qualcosa di specifico *Il prodotto separato.*, con degli utenti separati, che *Succede di consumare StyloExtract' la produzione come superficie del prodotto.*. Se lucidVIEW mostra un marcatore rottoM SK1 lucidVIEW' gli utenti si lamentano di lucidVIEW≥. Le lamentele si presentano in un rilevatore separato di problemi , con una fila separata per la triageMNK5 contro una versione separataMRK6 Dal punto di vista di StyloExtractMMK7 questo è un utente esterno che fa un erroreMSC8

Quella separazione è importante. Se lucidVIEW fosse uno strumento di test StyloExtract, l'ho scritto con un contenuto sterile perché so cosa piace all'extratoreM SK3 Poiché lucidVIEW esiste nel suo stesso mondo , punta al mio blog attualeMSC5 che usa TailwindMNK6 che produce la forma HTML indentata che scatena la classe degli erroriMMK7 L'errore è comparso in cinque minuti dall'uso del prodotto come utenteMسک8

Un sacco delle versioni v1.7.x rilasciano una traccia indietro a questo circuito. La versione strutturataM SK2walker output (vMSC4 è stata spedita perché lucidVIEWMスク5 la prima versione era inaccessibile quando StyloExtract stava emettendo paragrafi piattiMSSK6 Il primoMST7riparazione dello spazio bianco MSP8v+MST9 è stato spedito perché la versione post--Walker è stata visibilmente rotta sui siti TailwindMSL11 L'outputMS-Qualità lint harness è spedita perchè volevo che la prossima classe di errori fosse CIMSV13caughtMSS14

Il modello' non è nuovo. L'apporto di questo articolo lo chiamaM SK2 costruire un *Il prodotto di consumo.* in parallelo con il vostro. *Il prodotto di infrastruttura.*, e consumate la vostra stessa infrastruttura attraverso il consumatore. Il tasso di errore nella strata di infrastruttura diminuisce a seconda della superficie del consumatore. *Classe* La copertura all'infrastruttura si estende da tutto ciò che il consumatore nota che non pensava di scrivere un test per ..

## Loops chiusi in tutta la pila

StyloExtract / lucidVIEW è un esempio di schema che faccio girare su tutti i progetti.

```mermaid
flowchart LR
    PB[visible product<br/>behaviour] --> TR[test/review<br/>loop]
    TR --> AP[architectural<br/>primitive]
    AP --> RL[reusable<br/>library]
    RL --> WE[written<br/>explanation]
    WE --> LG[LLM-readable<br/>grammar]
    LG --> NP[next<br/>product]
    NP --> PB
```

Un errore si presenta in un prodotto. La soluzione diventa un'affermazione strutturale |(il test | / | Loop di revisione | L'affirmazione si trasforma in un primitivo che altre cose possono usare |. | Il primitivo diventa una biblioteca | . | La biblioteca viene scritta su |m. | Scrivere costruisce un LLM |M- | Grammatica legabile intorno alla base di codici |. | la grammatica rende il prossimo prodotto più veloce da costruire |. |Il prossimo prodotto si presenta con nuovi errori nelle biblioteche di cui dipende |

Il grafico di dipendenza che questo produce, attraverso i progetti che faccio, assomiglia aM SK2

```mermaid
flowchart TB
    LV[lucidVIEW<br/>Avalonia markdown reader]
    SB[StyloBot<br/>detection gateway]
    SF[StyloFlow<br/>policy + flow runtime]
    SE[StyloExtract<br/>HTML → Markdown]
    NA[mostlylucid.Naiad<br/>Mermaid renderer fork]
    DA[mostlylucid.dagre<br/>graph layout]
    EP[mostlylucid.ephemeral<br/>signal + sketch primitives]
    UT[Mostlylucid.Avalonia.UITesting<br/>Avalonia UI test framework]

    SB --> SF
    SB --> SE
    LV --> SE
    LV --> NA
    NA --> DA
    SE --> EP
    SF --> EP
    LV -.tests via.-> UT
```

`Mostlylucid.Avalonia.UITesting` chiude l'equivalente circuito sul lato del desktop. StyloExtract.Playwright lo chiude sullo lato senza testaM SK2web side . Lo stesso schemaMST4 una superficie diversaMTS5 un testMSS6 l'attrezzatura automatica permette al circuito agentico di verificare effettivamente il comportamento del prodotto che avrebbe dovuto riparareMSSK7

Il commercio è reale. Il grafico è incestuoso. Un errore in `mostlylucid.ephemeral` ripples up through StyloExtract and StyloFlow into StyloBot and lucidVIEW. The mitigation is time-boxed releases M SK2no feature creepMST3 the version ships when the scope it claims shipsMst4 and that every layer has its own test suiteMSt5 The benefit is that I can work across all'ensemble of them in a week and the cost of contextM st6switching between them stays lowM St7 because each layer has the same shapeMstr8 a primitiveM str9 a libraryM Str10 an explanationMtr11 a consumerMr12

Significa anche che scrivere questo articolo non è 't un latoM SK1effetto dello spedimento StyloExtract. La scrittura è una delle fasi . L'estrazione del design in prosa chiude il circuito che cominciò con " le carte di blog rendevano sbagliate nel lucidVIEWMSC5 e finisce con ♫"il prossimo consumatore di Stylo Extract ha un assistente strutturale e un contratto documentato per i passeggeriMNK7

Questo funziona per me perché i cicli di chiusura sono il modo in cui penso.

## Come lucidVIEW la collega?

```mermaid
flowchart LR
    URL[URL bar] --> Fetch[HttpClient GET<br/>Accept: text/markdown, text/html]
    Fetch --> Sniff{Content-Type}
    Sniff -->|text/markdown| Render[LiveMarkdown.Avalonia]
    Sniff -->|text/html| Convert[HtmlToMarkdownService]
    Convert --> Sparse{< 200 chars +<br/>SPA markers?}
    Sparse -->|yes| Stub[Stub: needs JS,<br/>open in browser?]
    Sparse -->|no| Render
    Stub --> Render
```

Quattro referenze per i pacchetti e un servizio:

```csharp
public sealed class HtmlToMarkdownService
{
    private readonly IHtmlDomParser _parser = new AngleSharpHtmlDomParser();
    private readonly IDomCleaner _cleaner = new DomCleaner();
    private readonly IBlockSegmenter _segmenter = new BlockSegmenter();
    private readonly IBlockClassifier _classifier =
        HeuristicBlockClassifier.LoadFromEmbeddedResources();
    private readonly IMarkdownRenderer _renderer = new TypedMarkdownRenderer();

    public string Convert(string html, Uri? sourceUri = null)
    {
        var doc = _parser.Parse(html, sourceUri);
        _cleaner.Clean(doc);
        var blocks = _classifier.Classify(_segmenter.Segment(doc));
        return _renderer.Render(blocks, ExtractionProfile.RagFull);
    }
}
```

Quello's it. Un'icona dello statusM SK2in la barra mostra quale percorso è stato condotto (direct MD | | / convertito \ / | SPA stub |) | così che quando qualcosa sembra sbagliato l'utente può puntare sulla strata destra.

![Wikipedia's Markdown article rendered inside lucidVIEW via StyloExtract : heading hierarchy intact, inline links liveM SK3 code sample preservedMSC4](lucidview-wikipedia.png)

## Il pipeline, in fase

```mermaid
flowchart LR
    HTML[HTML] --> P[AngleSharpHtmlDomParser]
    P --> C[DomCleaner<br/>strip script/style]
    C --> S[BlockSegmenter<br/>candidate subtrees]
    S --> K[HeuristicBlockClassifier<br/>role + score]
    K --> W[DomMarkdownWalker<br/>GFM per block]
    W --> R[TypedMarkdownRenderer<br/>profile gate]
    R --> MD[Markdown]
```

Parsatore + pulitore sono gli impacchettoni AngleSharpM SK1 Il codice interessante inizia dalla segmentazione.

**Segmentatore.** Camminare il corpo, immergere qualsiasi etichetta semantica (`main`/`article`/`section`/`h1`-`h6`/`ul`/`pre`/...) più qualsiasi `<div>`/`<section>` che' il suo testo- pesante o ha molti bambiniM SK2 Tutto il resto è troppo piccolo per essere soddisfatto :

```csharp
public IReadOnlyList<IElement> Segment(IDocument document)
{
    if (document.Body is null) return Array.Empty<IElement>();
    var result = new List<IElement>();
    Walk(document.Body, result);
    return result;
}

private static void Walk(IElement element, List<IElement> sink)
{
    if (SemanticTags.Contains(element.TagName))   sink.Add(element);
    else if (IsBlockyDiv(element))                sink.Add(element);
    foreach (var child in element.Children) Walk(child, sink);
}
```

**Classificatore.** Scolare ogni candidato, scegliere non-- vincitori sovrapposteM SK2 firmare con una targhetta. `BlockRole` (Contento principaleM SK1 Insieme, Navigazione , Pannello di boilerMSC4 Iteme ripetutoMST5 MST6 Ogni blocco scelto riceve il suo DOM camminato in Markdown strutturatoMSL7

```csharp
foreach (var element in selected)
{
    var role = ClassifyRole(element);
    yield return new ExtractedBlock
    {
        Role     = role,
        Text     = element.TextContent.Trim(),
        Markdown = ShouldRenderMarkdown(role) ? DomMarkdownWalker.Render(element) : "",
        Links    = ExtractLinks(element),
        XPath    = XPathBuilder.For(element),
        // ...
    };
}
```

**Walker.** Un StringBuilder, un pass DOM. Il `<a>` il caso è dove viveva l'errore v1.7.1

```csharp
case "a":
    var href = el.GetAttribute("href") ?? "";
    if (href.Length == 0) { WriteInlineChildren(dest, el); return; }
    dest.Append('[');
    WriteInlineChildren(dest, el);
    dest.Append("](").Append(href).Append(')');
    return;
```

(La soluzione era ' non c'era qui. Era in `AppendEscapedInline`, che alimentava lo spazio bianco principale. `dest` Prima che questo caso andasse avanti. Quattro spazi prima di `[` Rende CommonMark a analizzare la linea come blocco di codice.)

**Renderer.** Porta per profilo. `RagFull` Mantiene l'articolo-Contexto M SK1Breadcrumb,Links connessi ) e toglie il cromo MSC4FooterMSC5 HeaderMST6 CookieBannerMSS7 NavMSSS8 `MainContentOnly` Mantiene solo il corpo. `AgentNavigation` mantiene solo nav (for crawl-link discoveryM SK2

```csharp
return p switch
{
    ExtractionProfile.RagFull => b.Role is not (BlockRole.Footer or BlockRole.Header
        or BlockRole.Advertisement or BlockRole.CookieBanner or BlockRole.Boilerplate
        or BlockRole.Unknown or BlockRole.PrimaryNavigation or BlockRole.SecondaryNavigation),
    ExtractionProfile.MainContentOnly => b.Role is BlockRole.MainContent or BlockRole.Article
        or BlockRole.Heading or BlockRole.Summary or BlockRole.Table or BlockRole.CodeBlock
        or BlockRole.RepeatedItem,
    ExtractionProfile.AgentNavigation => b.Role is BlockRole.PrimaryNavigation
        or BlockRole.SecondaryNavigation or BlockRole.Breadcrumb or BlockRole.Form,
    _ => true
};
```

## Playwright per le pagine presentate da JS-

StyloExtract' l'euritica funziona sul HTML statico che il server ha restituitoM SK1 Quello' la multa per i siti SSR ( WikipediaMST4 GitHubMSSK5 most news article URLsMSC6 Si rompe sui gusci SPA dove il contenuto visibile è un cliente hydratatoMSL7 al di fuori da una blob JSONMS: `__NEXT_DATA__`, `__NUXT__`, `__APOLLO_STATE__`. Il HTML statico contiene tag meta + uno scheletro, e non c'è un corpo da trovare per il classificatore.

`Mostlylucid.StyloExtract.Playwright` Plugs in upstream of the pipeline. Implementa `IRenderedHtmlFetcher`, guida un Chromium senza testa tramite Playwright, aspetta l'idrazioneM SK2 e restituisce il postMST3JS HTML al resto dell'extratoreMSC4

```csharp
public async Task<RenderedHtmlResult> FetchAsync(
    Uri uri, RenderOptions? options = null, CancellationToken cancellationToken = default)
{
    var page = await _context.NewPageAsync();
    await page.GotoAsync(uri.ToString(), new PageGotoOptions
    {
        WaitUntil = WaitUntilState.NetworkIdle,
        Timeout = options?.NavigationTimeoutMs ?? 15000,
    });
    var html = await page.ContentAsync();
    await page.CloseAsync();
    return new RenderedHtmlResult { Html = html, FinalUrl = page.Url };
}
```

Lo stesso parser, pulitoreM SK1 segmentatore, classificatore , walkerMNK4 renderere in avalitoMSC5 L'unica differenza è che l'input HTML ora contiene il DOM idratato che ha prodotto la JS

```mermaid
flowchart LR
    URL[URL] --> Choice{Render mode}
    Choice -->|static| HTTP[HttpClient]
    Choice -->|js-required| PW[PlaywrightHtmlFetcher<br/>headless Chromium]
    HTTP --> Pipeline[parse → clean → segment<br/>→ classify → walk → render]
    PW --> Pipeline
    Pipeline --> MD[Markdown]
```

Questa è la risposta giusta quando un operatore gestisce il server StyloExtract-l'altro lato e un browser senza testa è una dipendenza ragionevole. Il CLI manda due binari per esattamente questa divisioneM SK2 `stylo-extract` (AOT, ~12MBM SK3 staticoMST4soloMSST5 e `stylo-extract-playwright` (~120MB una volta Playwright' il browser pack si atterraM SK2 JS -capaciMSC4

**lucidVIEW non lo usa.** lucidVIEW è un lettore di marcamento; la sua identità è "no ChromiumM SK2 nessun motore browser , nessun 200MB di dipendenze per una singola funzioneMSC5 Bundling Playwright metterebbe un browser senza testa all'interno di un'app il cui tonno è l'assenza di una sola funzioneSSK6 L'esempio SPA descritto qui sotto è la risposta corretta da lucidVIEWMST7 dalla prospettiva di MST8 dire all'utente che la pagina ha bisogno di un browserMst9 consegnarlo al loro SST10 Un serverMSST11 lateralmente l'operatore del StyloBot non ha queste restrizioni e benefici dal far funzionare entrambi i binari lateraliMTS12 tramiteMSS13 lateraleMSR14

## Ci sono state altre cose che si sono verificate con il dogfooding.

Quattro errori non - che formano lucidVIEW'l'incapacità di usare -il modo di manipolare :

- **La specifica che ha guidato v1.7.0.** La prima costruzione di paragrafi piatti emessi (`element.TextContent.Trim()` per blocco). Wikipedia era inaccessibile, le carte del blog avevano perso ogni anchoroM SK2 Invece di "fare meglioMSC4 ho scritto [`docs/styloextract-markdown-spec.md`](https://github.com/scottgal/lucidview/blob/main/docs/styloextract-markdown-spec.md) enumerare ciò che mancava: livelli di heading, cicli inlineM SK2 liste , tavole GFMMSC4 immagini di bloccoMST5 con quattro URL per riproduttoriMSSK6 Il lavoro strutturatoMSL7walker in vMSR8 implementa questo spezzone

- **pagine SPA.** `bbc.com/news` è il prossimo.jsM SK1 HTML statico è meta tag + a `__NEXT_DATA__` La bolla JSON hydratata da JS. StyloExtract restituisce correttamente ~1 char perché non c'è corpoM SK2 . lucidVIEW ora sniffs per i marker del sistema SSK4`__NEXT_DATA__`, `__NUXT__`, `__APOLLO_STATE__`, `data-reactroot`, `ng-version=`, `__REMIX_DATA__`) eM SK1 su output sparso + marker, rende un blocco *"Questa pagina usa {framework}; lucidVIEW non funziona con JavaScriptM SK3 Open in browserMSC4* BBC's URL article (`/news/articles/<id>`) servire la realtà `<main>` e convertire pulito. Homepage M SK1 asimetria dell'articolo, non un fallimento del convertitoreMSC3
  
  ![lucidVIEW loading bbc.comM SK1news:detects Next .jsMSC4 Rende un blocco spiegando il clienteMST5la rendere laterale non è supportataMSSK6non offre una versione apertaMSM7inMSV8distorsione del browserM SV9 La barra di stato mostra l'allarmeMSP10 fonte triangolareMSS11icona di modalità più un MSR12clientiM SS13la rendere lateraleMISS14 nessuna conversione possibileMSSA15tagMSSI16](lucidview-bbc-spa.png)

- **Semantici- pagine libere.** `example.com` non ha `<main>`, no `<article>`. L'euristica non restituisce nulla correttamenteM SK1 Lo cattura lo stesso flusso di blocchi.

- **Corretto-maM SK1emissioni inutili.** Il filtro della categoria del mio blog' è `<select><option>`. Il passeggero non trova niente. `<a href>`, emette il testo delle opzioni come un paragrafo *"Tutto .NET | | (41) | 3 | 4 | ACP | 5 | AI | 6* Correct (options are't linksMSC2 but visually awfulM SK3 Un futuro passaggio per i passeggeri potrebbe rilevare `<select>` e rilascia una lista di definizioni o `[42 categories]` sintesi.

In più un'ala di guardia che è venuta fuori dalla resistenza all'ovvio scorciatolo. Quando l'estrazione è sparsa la tentazione è " tornare indietro al Jina Reader / rM SK3jinaMSC4ai / TrafilaturaMST6 lucidVIEW non lo faMSSK7tMSP8 Trascorrere le URL degli utenti attraverso un terzoMSS9un lettore di parti rende il lettore una perdita di privacyMSM10 ogni URL che gli utenti visita scorre attraverso qualche altro operatoreMMS11 i logMSV12 La catena di fallimento rimane localeMISS13 marcamento direttoM SS14 poi HTML tramite StyloExtractMSS15 e poi un blocco di metadati da `<meta>` / `og:` tag, poi "open in browserM SK2 Ogni passo alimenta. *StyloExtract* una fetta diversa dalla stessa origine, o sintetizza dai dati che abbiamo giàM SK1 La risposta a "la quale non è stata convertita

## L'arbitrio arbitrario di copertura sul sito -

Quello in cui la catena di cibo per cani è brava a trovare è il risultato rotto su una buona pagina.

Ho un fumo-runner ora che porta una dozzina di siti reali-il mondo e scarica l'extratoreM SK2la fuoriuscita . sulla metà strutturalmente cooperativa MSC4 notizie (BBCMST6 GuardianMst7 WikipediaMSSK8 GitHub repo READMEsMS, GhostM.orgMSSK11 il cahiere dei cambiamentiMESK12 Hacker News MSSK13 il classificatore heuristico trova il contenuto principale pulito e la walker produce un lettoreMSV14 gradazione MarkdownMSP15 Da dieci a trent'anni kilobyte di prosa pulita per paginaMISSK16

Nell'altra metà - Allbirds (Shopify), NotionMSC3 la pagina di marketingM SK4 qualsiasi cosa costruita con una libreria di componenti Tailwind personalizzata ♫- il classificatore trova *niente.*. Zero MainContent blocks. Ogni candidato viene demolato a Boilerplate perché la pagina non usa `<main>` o `<article>` e i nomi di classe non'non corrispondono a nessuno dei modelli del framework-incoraggiamento del contenuto JSONM SK2

L'aggiunta di altre entrate a quel file JSON è il passo ovvio. E's anche whackM SK2a -moleMSC4 Ogni negozio di tema lancia nuovi nomi di classeMST5 Ogni versione di Shopify li cambiaMSSK6 Curare una lista di ogni tema CMSMSL7la sua classeCMS8il nomeare le convenzioni è un lavoro completoMS-e la lista è vuota il giorno in cui la pubblicateMSV10

La vera soluzione è un modello addestrato. Per-elementi caratteristiche M SK2identità col tasto di targhetta ,classaMSC4name hash bucketsMNK5 testoMRK6 lunghezzaMEK7 collegamentoMMK8densitàMBK9 forma di parenteMKK10 presenza dei progenitoriMGK11 in un piccolo gradienteMZK12classificatore gonfiatoMDK13 addestrata sul corpo marchiato WCXBMTK14 esportata a ONNX,, chiamata da .NET via `Microsoft.ML.OnnxRuntime`. Un 45- vettore di caratteristiche per ogni candidato, ~10μs di deduzione per ogni elementoM SK4 \~5File del modelloMBMSC6 AOTMska7 pulitoMske8 Il modello aumenta l'euristica \ - non lo rimpiazza \ 'non lo sostituisce \- promuovendo gli elementi dell'euristica che si sono abbassati quando la propria fiducia è alta\ .

La progettazione per questo è impegnata nella StyloExtract repo (`docs/ml-classifier-v2-design.md`). L'implementazione non è'non è finitaM SK2 La cosa su cui è bloccata la ' non è l'impegno di ingegneriaMSC5 ma la questione dell'operatore di " trasportare un binario nativo del Runtime MB ONNX nel gatewayMST9 il deploymentMSL10 o tenere ML come un pacco opzionale separato che l' operatore sceglie di inserirsi nella .

Intanto, quello che gli operatori ottengono è il *manuale* escape hatch: operatore - schemi YAML autorizzati, un file per ospiteM SK3 AOT parseggiatoMST4 pulitoMSL5 caldoMSC6 superando il tubo di indutzione per quell'ospite MST7 Un operatore nota che `weird-shopify-tenant.com` produce uno spazio vuoto Markdown, scrive un file YAML con cinque linee - che dice "per questo hostM SK3 Il contenuto principale è `.product-description-body`", lo inserisce. `config/templates/`, e l'intervallo lo raccoglie tramite FileSystemWatcher in pochi secondi. Il CLI ha `template add/list/show/remove/test`. La superficie REST ha `GET/PUT/DELETE/POST` per lo stesso . L'analisi di sicurezza automatizzata ha catturato la traversazione del percorso e il SSRF nei punti finali della REST durante l'analisi PR e questi sono stati corretti ; la suite di regressione per entrambe le vite nel progetto di prova ora

I modelli dell'operatore sono il pavimento. Il modello ML è il soffitto. Il fatto che ci sia un divario tra ' è onesto M SK3 l'euritica è la caricaMSC4 il percorso da trasportare oggi, , e rimanerà la carica,' la carica fino a quando il modello sarà addestrato e confrontato con la barrella WCXB F*1 . Questo lavoro non è stato fatto.

**La parte 2 di questa serie è la ML buildM SK1out.** Heuristico + perM SK1 i moduli di hosting gestiscono il bozzone - la coda lunga che si comporta e l'operatore МSK3 le bordure pieghevoli | ; | il classificatore addestrato gestisce il HTML davvero sporco |( | i temi diShopify | , | le pagine di marketing delle nozioni |, | biblioteche dei componenti Tailwind senza ancrazie semantiche | `<main>`, no `<article>`, nessuna classe coerenteM SK1 segnale di nome, e nessun posto ovvio per l'euritica da attaccare a . vMSC4 è la risposta a \" cosa dire delle pagine che non segueno alcuna struttura \ '

## Dove va?

StyloExtract è su NuGet come un insieme di pacchetti connessi: AbstractionsMSC1 Heuristics, FingerprintM SK3 CoreMST4 MarkdownMst5 AspNetCoreMSST6 Le navi CLI come `stylo-extract` (AOTM SK1 ~12MB singolo binario) e `stylo-extract-playwright` (with headlessM SK1browser fallback for JS-rended SPAsMSC3 La versione attuale è 1.7.1.

Il StyloBot integra le navi come `Mostlylucid.BotDetection.StyloExtract`. L'aggiunta ad una porta ha due linee. `Program.cs` E una regola. `appsettings.json`:

```jsonc
{
  "BotDetection": {
    "DetectionPolicies": {
      "Rules": [
        {
          "Name": "ai-bot-docs-markdown",
          "Path": "/docs/*",
          "Types": ["AiBot"],
          "Confidence": ">= 0.85",
          "Action": "extract-markdown"
        }
      ]
    }
  },
  "StyloExtract": {
    "Actions": {
      "extract-markdown": {
        "Profile": "RagFull",
        "Cache": {
          "Mode": "Override",
          "MaxAge": 3600,
          "VaryByBotType": true
        }
      }
    }
  }
}
```

Quello che questo dice: ogni richiesta che il rilevatore classifica come AI Bot, con fiducia almeno M SK2 che colpisce un percorso sotto `/docs/*`, riceve il suo corpo di risposta trasformato dal HTML al Markdown da StyloExtract prima di essere restituito. La stessa passerella continua a servire HTML ad ogni altra richiestaM SK2 Il crawler ha chiesto il Markdown essendo un crawler che ' è rumoroso per sé stesso nell'utenteMST4 agenteMst5 L'autore del sito continua a fornire HTML agli umani senza scrivere un sito Markdown separatoMSC6

Che' è il locale Markdown Mode. Non dipendeM SK2 non dipende da Cloudflare . Non costa per richiestaMska5 Funziona sulla stessa scatola di porta che avete giàMSC6 Il classificatore è StyloBotMST7 L'extratore è StiloExtractMSSK8 Tutto si adatta ad un AOTMSL9 binario pubblicato che potete deploire con `docker run` e lasciarlo da solo.

## Cosa c'è di nuovo qui?

Quello che le navi Cloudflare fanno è un fascino: il classificatore (la loro è proprietariaM SK2 l'extrattore ♫( probabilmente una forca di Trafilatura o simile ♫ ), la regola ♫

La stessa capacità, decomposta per un'auto-gateway controllataM SK2 sono quattro cose separate che compongono

1. Un classificatore che funziona localmente e decide "questo è un crawler AIM SK1 StyloBot.
2. Un estrattotore che funziona localmente e converte HTML in lettore-grade Markdown. StyloExtractM SK2
3. Una regola-superficia di valutazione che mappe giudizi per le azioniM SK1 `DetectionPolicyMiddleware` in StyloBot 7.x.
4. Il substrato su cui vanno tutti. Qualunque scatola sia la vostra porta YARP su.

Cloudflare possiede tutti i quattro. Un operatore autonomoM SK1 gestito possiede più di tre (il substrato è loro , il resto è FOSSMSC4 Il commercio è il più ovvioMST5 perdere l'influenza globaleMSL6benefici del networkMSR7 guadagnare l'operatoreMsl8controlliM SR9tuttoMSTR10eMSR11pagaMRS12nienteMRSS13per\MRS14benefitti di richiestaMSSR15

Il motivo per cui questo articolo esiste è che il secondo e terzo pezzo di quella decomposizione - l'extratore e la superficie della regola - non avevano una storia locale coerenta quando ho iniziato. Loro lo fanno oraM SK4 Il passeggero è quello che ha reso il risultato effettivamente usabileMSC5 La catena di cibo per cani con lucidVIEW è ciò che ha fatto corrispondere il passeggere in realtàMST6 L'operatoreMst7 la superficie del modello è ciò Che rende la coda lunga manodoperabile mentre la funzione ML è costruitaM st8

La parte 2 copre il classificatore ML per la confusione-HTML tailM SK2 Viene consegnata quando c'è qualcosa da mostrare '

---


*StyloExtract source: `github.com/scottgal/styloextract`. Il pacco di integrazione del StyloBot vive nel repo del stylobot come `Mostlylucid.BotDetection.StyloExtract`. lucidVIEW source: `github.com/scottgal/lucidview`. Il risultato CommonMark-qualità lint helper che ha chiuso il vM SK2 buco è in `tests/StyloExtract.Heuristics.Tests/MarkdownOutputLint.cs`.*