Insane C# Development

Dienstag, August 29, 2006

Screen scraping under .NET

Introduction

Es gibt Dinge über die beschwert man sich öfter, aber macht nicht wirklich was dagegen, weil der Drang hierzu einfach zu gering ist. Irgendwann kommt dann ein Auslöser der jemanden dazu bewegt etwas zu tun, was schon lange getan hätte werden müssen. Und nicht selten verursacht er dadurch das er sein privates kleines Problem löst einen Quantensprung, und tritt eine Lawine los die tausenden anderen Menschen ebenfalls das Leben erleichtert.

Genau so einen "Quantenzündungseffekt" erlebte ich neulich als ich mit meinem K800i allerhand Seiten abklapperte. Dessen XHTML Browser kann zwar alles zwischen WML, HTML, JavaScript und SVG Tiny darstellen, versagt aber peinlicherweise bei Framesets. Es bietet nichteinmal eine Liste der inkludierten Frames an, um Lynx-like dieses Manko zu umschiffen. Normalerweise ist das nicht so schlimm, Frames sind in letzter Zeit etwas aus der Mode gekommen, und es gibt ja das berühmte <noframes> HTML Tag, aber ich musste peinlich beim Prahlen zurückstecken als ich meinen Freunden vorführen wollte wie eine geliebte Internet-Messaging-Seite, ihrerseits sehr simpel im Aufbau, von meinem K800i einfach nur mit einem müden Lächeln ignoriert wird. Und warum? Weil man je nachdem wie man sich einloggt entweder an einem window.open() scheitert oder an einem Frameset ohne <noframes> Region.

Screen Scraping - Part Un

Was also tun um auch mobil nicht nur mit Jimm über ICQ chatten zu können, sondern eben genannte Chat/Messaging Seite verwenden zu können? Es nervte mich schon seit längerem das diese Seite kein XML-RPC Automation Interface oder ähnliches anbietet, denn im Büro wollte nich nie ein Chatfenster offen haben, von den Log-Einträgen mal zu schweigen. Der Traum von allen Benutzern dieser Seite wäre ein Miranda Plugin, kombiniert mit einem URL Webwasher.

Die Lösung war klar: Ein Screen Scraper musste her der sich als "Proxy" an der Seite anmeldet, die Anfragen durchführt, die HTML Ausgabe parst, und dann in K800i freundichem XHTML wieder ausgibt. Natürlich in zwei Layern, einerseits die Library die sich um das Screen Scraping kümmert, und andererseits ein Frontend (hier das XHTML rendering). Die 100%ige Seperation der beiden Layer ist nötig um den ScreenScraper ohne Probleme auch über XML-RPC oder sonstige Kanäle nach draußen anbieten zu können.

Screen Scraping - Part Deux

Sieht man sich den HTML Quellcode der Seite an bemerkt man schnell dass das Unterfangen gar nicht so schwer ist wie angenommen. Das Design ändert sich selten, und wenn dann nur in CSS Detailfragen. Es wird ein Mini Interface als Popup Fenster angeboten, das etwas weniger verdächtige URLs benutzt und dessen Layout sich seit Einführung nicht verändert hat. Der Minimalismus tut sein übriges um der perfekte Parsing-Kandidat zu sein.

Da ich schön öfter ScreenScraping Applikationen geschrieben habe wollte ich nicht schon wieder alles selbst tun, und erinnerte mich an einen Artikel über ein Modul in Perl, welches hier wunderbare Dienste leisten würde. Es bietet eine Shell an in der man in einem Textbrowser surfen kann, Aktionen durchführt, und diese durch das Modul aufzeichnen lässt. Das Modul merkt sich welche Felder gepostet werden, wo die Felder aus dem HTML Code herstammen, und erzeugt viel Gerüst-Code um danach auf dieser Basis ScreenScraping durchführen zu können.

Leider gibt es auch nach längerer Suche keine vergleichbare Library in .NET (sollte ich mich irren bitte Info an mich), und so musste ich wieder nach Schema F vorgehen. In Firefox browsen, Quellcode der Frames ansehen (da ist Mozilla einfach überlegen, es zeigt z.B. den Quellcode der ganzen Seite, eines Frames, oder auch nur des markierten Textes an - je nach Wunsch, und hat einen eingebauten DOM Inspektor), mit Ethereal sicherheitshalber alle POST Daten abfangen, und dann darauf basierend die Informationen in .NET zusammenkratzen.

Ich empfehle übrigens jedem System.Web.HttpWebRequest anstatt HttpClient zu verwenden. Letzterer hat zwar einige Convenience Funktionen, merkt sich aber keine Cookies. Die kann man nur retten indem man jedem HttpWebRequest dieselbe Referenz eines CookieContainers übergibt (den Request also am besten in einer Factory Methode erstellen!).
Da es außerdem oftmals mühsam ist die benötigten Informationen mit Regexen aus dem HTML rauszukratzen, HTML aber praktisch von keinem Menschen auf der Welt XML konform geschrieben wird, setzte ich mal wieder die seperate erhältliche und viel geliebte SgmlReader Klasse ein:

// navigate to comment page and well-form it
Sgml.SgmlReader sgmlReader = new Sgml.SgmlReader();
sgmlReader.DocType = "HTML";
sgmlReader.InputStream = new StreamReader(response.GetResponseStream());
sgmlReader.CaseFolding = Sgml.CaseFolding.ToLower;


// load html as well formed xml
xdoc = new XmlDocument();
xdoc.PreserveWhitespace = false;
xdoc.Load(sgmlReader);

Der SgmlReader vermag das zu tun was XmlDocument nicht kann: Kaputtes SGML (HTML ist nichts anderes) in XML zu überführen indem fehlende Tag-Enden an der richtigen Stelle eingefügt, Attribute korrekt notiert und Tags case-insensitive gemacht werden. Das Case-Folding ist wirklich wichtig, da man sich ansonsten zu Tode debuggt warum das Tag "a" nicht gefunden wird, wenn der Designer "A" geschrieben hat.

Da man nach dieser Prozedur ein XmlDocument erhält kann man das HTML Dokument danach mit XPath Queries abkratzen, und erspart sich sehr oft umständliche RegEx Konstrukte. Dies ist auch wichtig da der Web-Designer ja hier und da mal etwas am Layout drehen könnte. Man muss die XPath Query nur so geschickt formulieren dass sie auch dann noch klappt wenn davor noch Elemente eingeschoben werden. Beispiel:
xdoc.SelectNodes("//table/tr/td/a")
xdoc.SelectNodes("//table//tr/td[@class='resHeadline']/a[starts-with(@href, '../auswertung/setcard')]")

Die doppelten Slashes stellen dies sicher. Leider muss man jedoch immer wieder auf Hacks zurückgreifen und kann sich nur retten indem man nach dem CSS Klassennamen oder einem Style attribut abfragt.

Nachdem dies geklärt war fand ich mich vor den klassischen Fragen wieder:
  • Benutzt die Seite Cookies?
    Nicht das mir aufgefallen wäre.
  • Welche hidden fields werden eingesetzt (input type=hidden)
    Nur beim Login.
  • Überprüft die Seite den User-Agent?
    Nein, er wird trotzdem auf IE 6 gesetzt
  • Überprüft die Seite den Referer?
    Nein.
  • Wann erhält man die Session ID, wie wird sie übermittelt.
    Double-Bogus-Sessions (siehe weiter unten)
  • Wie sehen die Formulare für die Nachrichten, Suchanfragen und History aus
  • Wie ist der Aufbau der HTML Seiten für die eingehenden Nachrichten, etc.


Login Seite

Die Login Seite ist immer die größte Hürde. Hier erhält man meist die Session ID in Form eines Cookies, deshalb sollte man schon hier mit dem Cookie aufzeichnen starten:
private CookieContainer _cookies = new CookieContainer();
private HttpWebRequest CreateWebRequest(string href)
{

HttpWebRequest request = (HttpWebRequest)WebRequest.Create(href);
request.CookieContainer = _cookies;
request.Expect = null;
request.KeepAlive = false;
request.Accept = "image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, application/vnd.ms-powerpoint, application/msword, application/vnd.ms-excel, */*";
request.Headers["Accept-Language"] = "de";
request.ContentType = "application/x-www-form-urlencoded";
// Accept-Encoding: gzip, deflate············
request.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727)";

return request;
}


Auch wenn es lächerlich aussieht alle Content-Types auf diese Art anzugeben, wenn man doch weiß das niemals ein Word Dokument gesendet wird, dies ist nunmal das was Marktübliche Browser senden, und wir wollen uns schließlich nicht verdächtig machen.

In der Tat hatte gibt es eine kleine Überraschung auf dieser Seite mit der Session ID, warum man übrigens auch immer Ethereal mitlaufen lassen sollte. Die Session wird über URLs codiert (das wußte ich schon) und in der Form http://www.chatseite2000.blubb/ASDF22347sdfASF234sfASDFASEf23ASF2JSf/message.php übergeben. Allerdings benutzt die Seite "Double-Bogus-Sessions", erzeugt also bei der Anforderung der Loginseite eine "nutzlose" (bogus) PHPSession, codiert diese in einem hidden field, erzeugt nach erfolgreicher Anmeldung eine weitere, und teilt sie dem Browser über einen Redirect mit. (Der entweder über Framesets oder über window.open realisiert wird, was mein K800i überfordert).

So ein Szenario habe ich allerdings schon auf einer Free-SMS Seite in den 90ern gesehen (als das in Österreich noch selbstverständlich war), und war deshalb zwar überrascht, aber nicht überfordert.

HTML Parsing

Nachdem die Loginhürde überwunden war galt es rauszufinden wie die einzelnen Messages und Online-Benutzer aufgelistet werden. Da ich mich für das Scrapen des mini-Messengers entschloß war die HTML Struktur wie erwartet sehr einfach aufgebaut. Wichtig ist immer das man sich bei solchen sich wiederholenden Einträgen ansieht was die Seite ausgibt wenn nichts, ein Datensatz, und mehrere Datensätze vorhanden sind. Sehr oft zerreißt es einen Parser bei diesem Detail.
In unserem Fall zeigt die Seite alle Absender und die Uhrzeit an (seltsamerweise aber nie das Datum!!), öffnet die Nachricht bei einem Klick darauf und markiert sie dann als gelesen. Die Einträge sehen etwa so aus:

<TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0" WIDTH="130">

<TR>
<TD WIDTH="100%">

<TR><TD ALIGN="center" HEIGHT="18">
<a href="../../msg/?id=#######" onClick="return openMessage(#####)">
<IMG BORDER="0" SRC="######/smile.gif" alt="######">
BENUTZERNAME 22:35 </a>
</TD></TR>
</TD>

</TR>
</TABLE>


Man beachte das fehlerhafte HTML. Da jemand vielleicht mal korrekterweise ein TABLE Tag einfügen wird, muss der Nodeselect (siehe oben) so gewählt werden, dass es auch dann noch funktioniert. Seltsam ist auch das immer darauf geachtet wird auch ohne JavaScript korrekt zu funktionieren. Leider hilft das bei meinem K800i nicht. Es interpretiert das JavaScript traurigerweise, und würde wirklich versuchen ein neues Fenster zu öffnen, leider ohne Erfolg. Deshalb ja auch dieser ScreenScraper.
Die Informationen sind nun relativ leicht rauszuparsen. Die Message ID, welche man benötigt um den Message-Inhalt zu bekommen, ist im HREF oder im JavaScript vorhanden. Sicherheitshalber sollte man sich für das HREF mit einem geeignet flexiblen Regex entscheiden, vielleicht werden ja noch weitere Query Parameter in Zukunft angehängt? Der Benutzername und das Datum stehen leider nicht seperat in einem Knoten, lassen sich aber entweder dadurch ermitteln indem man das Image (ein smiley) sucht, und den darauffolgenden Knoten nimmt, oder indem man einen Textknoten sucht auf den ein bestimmtes Regex passt. Aus Faulheit habe ich mich für das Erstere entschieden, aber sicher wäre das zweite (falls mal jemand noch etwas zusätzlich einbaut).

Die Nachricht selbst erhält man indem man die Seite öffnet die im HREF angegeben wurde. Dies macht man aber am Besten erst nachdem der Benutzer den Wunsch geäußert hat diese Nachricht zu lesen, ansonsten markiert man die Nachricht zu früh als gelesen.

Die restlichen Seiten, Benutzersuche, Online Benutzer, Favoriten, etc., lassen sich auf ähnliche Weise parsen, pro Seite benötigte ich, nachdem das Grundgerüst stand, vielleicht 10 Minuten. Die Übersicht der empfangenen und gesendeten Nachrichten benötigte länger, und ist zwar Threadsafe, aber nicht Race-Condition Safe, wenn noch jemand anders über einen Webbrowser im Profil eingeloggt ist.... Bitte nicht fragen, oder höchstens an einem Stammtisch! :-) Ich sage nur: Es ist DEREN Schuld! :-)

Achtung: Wie man sieht nehmen es die Betreiber der Seite nicht so genau mit HTML, bzw. sie sind nicht XHTML konform. Obwohl viele Browser einige Schweinereien in HTML zulassen hätte man schon vor XHTML den HTML Standard korrekt einhalten sollen. Grml. Das hat zur Folge das manchmal ein "Was du siehst, ist nicht was SGML sieht" Problem auftritt. Wir erinnern uns: Der SGML Parser transformiert von fehlerhaftem HTML in korrektes XML, entfernt fehlerhafte Attribute (Attribute ohne Wertzuweisung), und fügt schließende Tags ein wo welche nötig sind. Es kann also passieren das ein Textknoten plötzlich unterbrochen wird (die Seite fügt nach einem ü aus irgendeinem Grund immer einen Soft-Break (<WBR>) ein), oder Geistertags entstehen weil die vorhergehenden nicht beendet wurden. Hier hilft der VisualStudio.NET 2005 XML Debug Inspektor, um die InnerXML Knoten zu visualisieren. Man sieht dann zur Laufzeit was der SGML Parser daraus gemacht hat, und kann so live Änderungen einpflegen.

Übrigens ist bei solchen Seiten ein Arbeiten mit Edit&Continue unabdinglich, da tausende Logins oftmals lästig sind, vielleicht sogar schräg auffallen könnten. Edit&Continue für C# von VS.NET 2005 plus die neuen XML/HTML String Inspektoren leisten hier gute Dienste - mein Scraper ist auch zur Laufzeit programmiert worden :-)


Details, Details, Details

Beim ScreenScraping ist immer darauf zu achten dass man sich wie ein Browser verhalten muss, sonst fällt es stark auf, oder hat zumindest blöde Seiteneffekte. Man kann zwar alle Bilder links liegen lassen (kann man in einem normalen Browser schließlich auch abstellen), sollte aber niemals Backgroundscripte und http-equiv Reloads vergessen. In der Tat war ich zunächst so naiv zu glauben es würde reichen alle 60 Sekunden die Liste der neuen Nachrichten abzufragen um die PHP Session am Leben zu erhalten. Da ich fröhlich weiterchatten konnte fiel mir auch nicht sofort auf das mich die Seite nach einigen Minuten für offline erklärt hatte, zumindest zeigte sie dies an, und begann mir, wie eingestellt, die Nachrichten für mich per Mail zukommen zu lassen.
Der Trick war das per JavaScript eine Heartbeatseite in 60 Sekunden Intervallen aufgerufen wird, und zwar die ersten drei mal mit drei verschiedenen Request Parametern. Paralell dazu wird in einem anderen Frame via http-equiv Refresh in 70 Sekunden Intervallen ein anderes Skript aufgerufen, vermutlich aber nur zur Sicherheit sollte das JavaScript aufgrund der Sicherheitseinstellungen versagen.
In die Library wanderte also ein Heartbeat Call, der nun von jeder Client Applikation selbst aufgerufen werden muss. Entweder via Background-Timer, oder (wie folgend) jedes Mal beim Abrufen der ungelesenen Nachrichten.


Screen Scraping - Part Trois

Library hin oder her, das Ziel war keine Debug-Console-Application, sondern eine ASP.NET Seite welche schlank auf meinem Handy gerendert wird. Der erste Versuch ging schief, die ASP.NET Seite wurde auf dem K800i nicht angezeigt, obwohl sie in einem 08/15 Browser funktionierte. Erster versuch: statische HTML Seite - klappte. Nach längerem Hin und Herprobieren (ist es die Endung .aspx?) entschloß ich mich eine Dummy "Mobile ASP.NET" Seite auszuprobieren (einer der großen Vorteile von ASP.NET gegenüber PHP - einer der etwa 17 von mir gezählten). Die wurde als WML am Telefon korrekt angezeigt, als HTML im Browser.
Da fiel mir plötzlich ein das ASP.NET eine Browser-Erkennung hat. Als ich mir also via Ethereal die Header ansah, bemerkte ich das das K800i ein Mobile Profile mitsendet, und als Accept-Type wml, und xhtml angibt, aber HTML nicht explizit erwähnt (nur über */*). ASP.NET geht also davon aus das es sich um ein Mobiles Gerät handelt, und versucht sein Bestes dem gerecht zu werden, scheitert allerdings an meiner Faulheit. Es rendert daraufhin mein HTML, gibt aber als Antwort "WML" als ContentType an.
Die Lösung war einfach. Response.ContentType = "text/html"; überschrieb einfach den von ASP.NET generierten ContentType - fertig. Die Seite wurde dann wie im Browser als 08/15 HTML gerendert - jippie!


Irgendwie hat's auf dem Handy mehr Charme, als im Browser :-)

Als dann alles soweit klappte wurde die Kommunikation via SSL verschlüsselt (OpenSSL für eigenes Zertifikat, Handy so einstellen das es dies akzeptiert!), und via httpCompress die gzip Kompression aktiviert. Wer ein Server-Betriebssystem hat (Win2K Server, 2K3 Server, etc.) kann die Kompression auch bequem mit einem Klick über IIS Admin aktivieren, alle anderen (wie ich mit meinem WinXP Pro) müssen dies mit besagtem httpCompress Modul erledigen. SSL und gzip sind unabläßlich, es handelt sich ja schließlich um mobile Geräte, und trotz Flatrate und UMTS ist dank fehlendem HSDPA in Deutschland mobile Bandbreite immer noch ein Thema.

Screen Scraping - Fin

Mittlerweile ist das Gateway bei mir im regelmäßigen Einsatz, es ist ideal um sich Mobil die Zeit zu vertreiben, zb. bei längeren Zugfahrten, oder auch um mal schnell in der Firma zu chatten. Als Erweiterung wäre noch das Umwandeln der HTML Seiten in ASP.Mobile Seiten. Dies würde vielleicht 30 Minuten in Anspruch nehmen, und würde es auch nicht XHTML fähigen Telefonen erlauben die Chatseite via WAP zu bedienen, während "echte" Browser weiterhin HTML geliefert bekommen. ASP.NET sei dank für diese Automagik. Da ich persönlich aber kaum Nutzen daraus ziehe bleibt das mal liegen, auch wenn es noch so einfach zu machen wäre.
Als finale Erweiterung wäre es noch genial die Lib in ein Miranda-Plugin zu flanschen, dies würde in der Firma dann den geringsten Verdacht erregen - denn Miranda usw. ist ja stillschweigend erlaubt.

Groß publik machen werde ich es auch nicht, geschweige denn weitergeben. Ich weiß das mit dieser Library, wenn ich sie veröffentlichen würde, Chatbots gebastelt und fieser Schabernack getrieben werden WIRD. Es ist traurig, aber die Geschichte lehrt mich das Menschen solche Werke leider oft mißbrauchen. Ich habe bewießen das es geht, und es ist bei mir im Einsatz. Mehr brauch ich für mich als Ingenieur nicht. Außerdem hoffe ich darauf das die Betreiber ENDLICH selbst ein WAP Gateway schreiben, schließlich ist dies ein sehr oft genanntes Feature.

Und dieser Artikel existiert auch nur um ScreenScraping unter .NET zu demonstrieren, oder zumindest jemanden anzuspornen eine bessere Automatiserungslib zu schreiben ;-)

Over and out.