Einfacher Headless Browser mit HttpClient und Html Agility Pack

.NET hat aus meiner Sicht eine Schwäche: es gibt keinen aktuellen Headless Browser, den man direkt in .NET und auf dem Server benutzen kann.

Man kann natürlich PhantomJS oder NHtmlUnit benutzen, aber auch damit gibt es Probleme.

PhantomJS ist super und ich benutze es bei einigen Projekten. Bei Downloads hat man aber ein Problem, zu mindestens mein letzter Stand. Dies ist nur auf Umwegen oder gar nicht möglich.

NHtmlUnit ist ebenfalls sehr gut, hat allerdings das Problem, dass es meiner Meinung nach langfristig nicht mehr geben wird, da IKVM.net eingestellt wurde.

Beide haben jedoch ein großes Problem: sie sind nicht besonders ressourcenschonend.

Ich habe für ein Projekt eine schnelle und einfache Lösung gebraucht. Nach einer kurzen Analyse habe ich festgestellt, dass Javascript für das Abfragen der Inhalte nicht benötigt wird. Daher habe ich eine kleine Klasse geschrieben, die auf dem .NET HttpClient und dem Html Agility Pack basiert:

using System;
using System.Linq;
using HtmlAgilityPack;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;

public class HttpClientExtended : IDisposable
{
    private HttpClientHandler httpClientHandler;
    private HttpClient httpClient;
    private string lastUrl = "";

    public HttpClientExtended(Uri baseAddress) : this()
    {
        httpClient.BaseAddress = baseAddress;
    }

    public HttpClientExtended()
    {
        httpClientHandler = new HttpClientHandler();
        httpClientHandler.UseCookies = true;
        httpClient = new HttpClient(httpClientHandler);
    }

    public async Task GetHtmlAsync(string url)
    {
        lastUrl = url;
        var response = await httpClient.GetAsync(url);        
        return await response.Content.ReadAsStringAsync();
    }

    public async Task GetBytesAsync(string url)
    {
        var response = await httpClient.GetAsync(url);
        return await response.Content.ReadAsByteArrayAsync();
    }

    public async Task PostFormAndGetHtmlAsync(HtmlNode node, IDictionary inputValues)
    {
        var inputEntries = new Dictionary();
        foreach (var inputNode in node.SelectNodes(".//input"))
        {
            if (inputNode.Attributes["type"].Value != "submit")
            {
                inputEntries.Add(inputNode.Attributes["name"].Value, inputNode.GetAttributeValue("value", ""));
            }
        }

        foreach (var value in inputValues)
        {
            inputEntries[value.Key] = value.Value;
        }

        var nodeForm = node.Name == "form" ? node : node.SelectSingleNode(".//form");
        var formUrl = nodeForm.GetAttributeValue("action", lastUrl);
        var postContent = new FormUrlEncodedContent(inputEntries.ToArray());

        var response = await httpClient.PostAsync(formUrl, postContent);
        return await response.Content.ReadAsStringAsync();
    }

    public void Dispose()
    {
        httpClient.Dispose();
        httpClientHandler.Dispose();
    }

    public static HtmlDocument GetHtmlDocument(string html)
    {
        var document = new HtmlDocument();
        document.LoadHtml(html);
        return document;
    }
}

Dies ist keine großartige Implementierung, aber ausbaufähig und für den Anfang ausreichend.

Da bei einem Login meistens Cookies notwendig sind. Habe ich diese standardmäßig aktiviert.
An der gleichen Stelle kann man sich auch überlegen, ob man nicht httpClientHandler.AllowAutoRedirect = false setzt, dann muss man zwar auf Redirects selber reagieren, aber kann so die aktuelle URL, falls benötigt, auslesen (z.B. ab Zeile 30 über response.Headers.Location).

Die Methode PostFormAndGetHtmlAsync ist die komplizierteste.
Zuerst werden alle notwendigen Werte aus den Input-Tags ausgelesen, da auch teilweise Hidden-Inputs verwendet werden, und dann mit den Benutzereingaben überschrieben.

GetHtmlDocument ist nur eine Helper-Methode, die in einer späteren Implementierung eher in eine Helper-Class ausgelagert werden sollte.
Zu Darstellungszwecken habe ich dies nicht gemacht.

Vielleicht noch ein Wort zu lastUrl. Diese speichere ich, damit ich auf diese zugreifen kann, falls mal kein action-Attribut in der Form vorhanden ist.

Der Rest sollte selbsterklärend sein. Wenn nicht, beantworte ich eure Fragen gerne in den Kommentaren.

Im Folgenden möchte ich euch noch zeigen, wie man diese Klasse benutzen kann um sich in einen Piwik-Account anzumelden und dann gleich abzumelden.

class Program
{
    static async Task Test()
    {
        using (var httpClient = new HttpClientExtended(new Uri("https://www.xyz.de/piwik/")))
        {
            var html = await httpClient.GetHtmlAsync("");
            Xunit.Assert.Contains("Sign in", html);

            var inputValues = new Dictionary() {
                { "form_login", "testuser" },
                { "form_password", "meinPasswort" },
            };
            var htmlDocument = HttpClientExtended.GetHtmlDocument(html);
            var formNode = htmlDocument.GetElementbyId("login_form");
            html = await httpClient.PostFormAndGetHtmlAsync(formNode, inputValues);
            Xunit.Assert.Contains("action=logout", html);

            htmlDocument = HttpClientExtended.GetHtmlDocument(html);
            var logoutUrl = htmlDocument.GetElementbyId("topmenu-login").Attributes["href"].Value;
            html = await httpClient.GetHtmlAsync(logoutUrl);
            Xunit.Assert.Contains("Sign in", html);
        }
    }

    static void Main(string[] args)
    {
        Test().Wait();
    }
}

In diesem kleinen Beispiel sieht man wie ihr das Html Agility Pack verwenden könnt um ganz einfach auf einzelne HTML-Elemente zu zugreifen.

Der Methode PostFormAndGetHtmlAsync kann man sicherlich auch das gesamte Dokument übergeben um einen Submit auszuführen.
Wenn mehrere Form-Tags vorhanden sind, dann könnte es evtl. zu Problemen kommen, daher lieber gleich die passende Form im DOM raussuchen.

Hinweis
Damit du das Programm testen kannst, musst du folgende 2 Pakete in der Paket-Manager-Konsole (Visual Studio -> Menü:Extras -> NuGet-Paket-Manager) installieren:
Install-Package xunit
Install-Package HtmlAgilityPack

Fazit

Ich finde die Lösung, wenn man was Einfaches sucht, in Ordnung.

Vor kurzem bin ich tatsächlich auf ein .NET Browser gestoßen: Optimus
Ich habe mit diesem noch keine Erfahrung sammeln können, werde es mir aber bei nächster Gelegenheit anschauen.

Was haltet von der Lösung? Braucht ihr Headless Browser? Ich komme um diese jedenfalls nicht herum, da ich sehr viele Import-Schnittstellen programmiere.
Auch bei Web-Oberflächen-Tests finde ich Headless Browser sinnvoll.

Kennt ihr vielleicht einen aktuellen und empfehlenswerter Headless Browser für .NET?

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.