Daten|teiler
Kopieren als Kulturtechnik

Datenpulen mit PowerShell: HTML-Dateien bearbeiten

12. August 2013 von Christian Imhorst

Daten aus einer HTML-Datei herauszupulen ist meistens sehr fehleranfällig und sollte immer nur Plan C sein. Die Funktion Get-MarkupTag von James Brundage macht es aber etwas leichter. Zu Get-MarkupTag gehört auch noch Get-Web, um den Quelltext der Seite herunterzuladen, bevor man ihn mit Get-MarkupTag parst. Da Get-Web ein relativ kurzes Skript ist, das hauptsächlich um das .NET-Objekt Net.Webclient gebaut wurde, kann man es sich auch leicht selber basteln. Das Skript Get-Web.ps1 von Brundage habe ich aber noch um folgende Zeilen ergänzt,

$webclient.Encoding = [System.Text.Encoding]::UTF8    # set UTF8 encoding
$webclient.UseDefaultCredentials = $true              # Default Credentials
$webclient.Proxy.Credentials = $webclient.Credentials # Proxy Credentials

damit die Umlaute von Websiten richtig dargestellt werden und damit sich das Skript mit den Anmeldeinformationen des gegenwärtig angemeldeten Benutzers authentifiziert, wenn der Server das verlangt.

Bei meiner Arbeit benutze ich Get-MarkupTag um Informationen wie Zählerstände, Tonerstatus, Fehlermeldungen etc. von Netzwerkdruckern auf deren eigenen HTML-Seiten auszulesen, und um Daten aus SharePoint-Seiten herauszufiltern. Was ich bislang noch nicht genutzt habe, ist die Möglichkeit, die HTML-Ausgabe eines Cmdlts nach XML zu parsen, da mir dafür noch kein Anwendungsfall untergekommen ist:

$text = Get-ChildItem | Select Name, LastWriteTime | ConvertTo-HTML | Out-String
Get-MarkupTag "tr" $text

Das sind aber alles Skripte, die ich hier schlecht als Beispiele zeigen kann, um Get-MarkupTag vorzustellen. Daher habe ich das kleine Cmdlet Get-Wetter geschrieben, das die aktuelle Wetterlage in der PowerShell anzeigt. Die Daten dafür bekommt es von der Website Wetter.de.

Zum Starten lädt man den Quelltext der Website herunter, wofür man das Skript Get-Web.ps1 nimmt, oder man schreibt sich ein vergleichbares. Ich benutze hier Get-Web:

$url = "http://www.wetter.de/deutschland/wetter-hannover-18219670/wetterbericht-aktuell.html"
$html = Get-Web $url

Die URL zeigt das aktuelle Wetter von Hannover an. Wen das nicht interessiert, sollte eine andere URL nehmen. Der Quelltext der Seite wird dann in die Variable $html geschrieben. Um zu wissen, welche Daten man aus der Website herausziehen will, sollte man schon einen guten Überblick über die HTML-Syntax und den Aufbau der Seite haben. Dann kann man zum Beispiel schnell erkennen, dass ein paar sehr schöne Informationen zum Wetter im Tooltip einiger IMG-Tags stehen.

WetterDe1

Also parse ich alle IMG-Tags des HTML-Dokuments nach XML, die das Attribut class="iconWeather tooltip" enthalten:

Get-MarkupTag img $html | Where-Object { $_.Tag -like '*class="iconWeather tooltip"*' } | % { $_.Xml }
 
src    : http://cdn.static-fra.de/wetter11/css/images/icons.wetter.01/64x64.wetter/wolke_klein-sonne.png
width  : 64
height : 64
border : 0
alt    : wetter-wolke_klein-sonne
title  : Wetter#@#Es ist sonnig, nur vorübergehend entstehen Quellwolken oder es ziehen lockere Wolkenfelder vorüber. Dabei bleibt es weitgehend trocken.
class  : iconWeather tooltip
 
src    : http://cdn.static-fra.de/wetter11/css/images/icons.wetter.01/64x64.wind/w.png
width  : 64
height : 64
border : 0
alt    : wind-West
title  : Wind#@# Der Wind weht aus Westen
class  : iconWeather tooltip

Wie man sehen kann, stehen die Tooltipps in den Title-Attributen der IMG-Tags, die ich auslesen möchte, z.B. „Wind#@# Der Wind weht aus Westen“. Deshalb werden auch nur die Title-Informationen geparst. Davon interessiert mich nur der zweite Teil des Strings, also alles was nach der Zeichenkette „#@#“ steht:

$out    = Get-MarkupTag img $html | Where-Object { $_.Tag -like '*class="iconWeather tooltip"*' } | % { $_.Xml.title }
$wetter = ($out[0].substring( ($out[0].lastIndexOf( "#@#" ))  + 3)).trim()
$wind   = ($out[1].substring( ($out[1].lastIndexOf( "#@#" ))  + 3)).trim()

Anstelle von Substring mit LastIndexOf könnte man auch die Methode Split aus der Klasse String nehmen, ersteres soll aber ressourcenschonender sein.

Nachdem ich die Beschreibung von Wetter und Wind in Variablen verstaut habe, geht es um das Regenrisiko und die Sonnenstunden, die sich in einer Tabelle befinden:

<table>
   <tr>
      <td class="description">Regenrisiko</td>
      <td><span class="bold">2%</span></td>
   </tr>
   <tr>
      <td class="description">Sonnenstunden</td>
      <td><span class="bold">9</span></td>
   </tr>
</table>

Da es mehrere Tabellen im HTML-Quelltext gibt, sich die von mir gesuchte Information glücklicherweise in der einzigen Tabelle befindet, die keine zusätzlichen Attribute hat, parse ich also nur den Inhalt der Tabelle, die mit einem <table> beginnt und speicher das Ergebnis aus den Tags, die ich mit Select erweitere, in die Variable $tbl:

$tbl = Get-MarkupTag table $html | Where-Object { $_.Tag -like '<table>*' } | Select-Object -expandProperty Tag

Wird die Tabelle von Get-MarkupTag verarbeitet, scheint aber etwas zu fehlen:

Get-MarkupTag "td" $tbl | % { $_.Xml }
 
class                     #text                                                                          
-----                     -----                                                                          
description               Regenrisiko                                                                    
 
description               Sonnenstunden

Die Werte von „Regenrisiko“ und „Sonnenstunden“ stehen zwischen SPAN-Tags und werden daher nicht berücksichtigt. Get-MarkupTag hat Besonderheiten bei Tags wie SPAN und DIV, doch dazu später mehr. Damit auch die Werte dort stehen, braucht man einen kleinen Trick: Die SPAN-Tags werden mit dem Class-Attribut „value“ des TD-Tags getauscht:

$tbl = Get-MarkupTag table $html | Where-Object { $_.Tag -like '<table>*' } | Select-Object -expandProperty Tag 
$tbl = $tbl -replace '><span class="bold"', ' class="value"'
$tbl = $tbl -replace '</span>', ''
Get-MarkupTag "td" $tbl | % { $_.Xml }
 
class                      #text
-----                      -----                                                                          
description                Regenrisiko                                                                    
value                      2%                                                                             
description                Sonnenstunden                                                                  
value                      9

Im Skript selbst speichere ich alles in der Spalte „#text“ in der Listen-Variable $regen. PowerShell macht automatisch eine Liste aus den Werten, ohne dass ich etwas dafür tun muss.

$regen = Get-MarkupTag "td" $tbl | % { $_.Xml.'#text' }

Die Info über die Temperatur, also wie warm oder wie kalt es heute wird, liegt zwar eingebettet zwischen SPAN- und DIV-Tags, sie können aber mit Get-MarkupTag nur schwer geparst werden. Das Cmdlet prüft nämlich die Anzahl der Start- und Endtags im Dokument, sind sie ungrade, nimmt es nur die Starttags und beachtet die Endtags nicht. Solche Tags werden dann wie nicht geschlossene Tags behandelt, also wie IMG- oder das BR-Tag, und in sich geschlossen:

Get-MarkupTag div $html | Select-Object -expandProperty Tag
 
[]
<div class="forecast-day-container light no-overview"/>
<div class="forecast-date"/>
<div class="forecast-day-overlay"/>
<div class="overlay-container-left"/>
<div class="forecast-day-temperature tooltip" title="Temperatur#@#MIN / MAX"/>
<div class="forecast-day-additional"/>
[]

Im Fall von DIV- und SPAN-Tags scheint Get-MarkupTag ungleiche Werte im HTML-Dokument zu ermitteln, weshalb sie als leere Elemente behandelt und die Starttag geschlossen werden. Bleibt also für die Suche nach den Gradzahlen im Dokument nur der alte, herkömmliche Weg mit einem Regulärer Ausdruck (RegEx):

$Temp  = ([regex]‘.\d{1,2}°C’).matches($html) | foreach {$_.Groups[0].Value}
$TempMin = ($Temp[0]).trim()
$TempMax = ($Temp[1]).trim()

Da der Reguläre Ausdruck ‘.\d{1,2}°C’ mehrfach vorkommt, gruppiere ich das Ergebnis und lese die Werte einzeln aus dem durch die Gruppierung gelieferten Array wieder aus. Gesucht wird eine Zahl von 0 – 9 (\d), die eine oder 2 Stellen hat ({1,2}), sollte davor exakt ein Zeichen (.) stehen, wird das auch mitgenommen, z.B. ein Minus-Zeichen bei Minus-Temparaturen, und am Ende der Zahl muss °C stehen. Falls Leerzeichen am Anfang oder am Ende der Zeichenkette stehen, werden sie mit der String.Trim-Methode abgeschnitten.

Das vollständige Skript bindet die Cmdlets Get-Web und Get-MarkupTag als Skripte ein. Alternativ kann man sie natürlich auch direkt mit in das Skript aufnehmen:

Set-StrictMode -Version "2.0"
Clear-Host
$Scriptpath = "$env:USERPROFILE\Powershell"
 
. $Scriptpath\Get-Web.ps1
. $Scriptpath\Get-MarkupTag.ps1
 
function Get-Wetter([string]$url){
    $html = Get-Web $url
 
    # Get IMG-Tag title
    $out    = Get-MarkupTag img $html | Where-Object { $_.Tag -like '*class="iconWeather tooltip"*' } | % { $_.Xml.title }
    $wetter = ($out[0].substring( ($out[0].lastIndexOf( "#@#" ))  + 3)).trim()
    $wind   = ($out[1].substring( ($out[1].lastIndexOf( "#@#" ))  + 3)).trim()
 
    # Get table 
    $tbl   = Get-MarkupTag table $html | Where-Object { $_.Tag -like '<table>*' } | Select-Object -expandProperty Tag 
    $tbl   = $tbl -replace '><span class="bold"', ' class="value"'
    $tbl   = $tbl -replace '</span>', ''
    $regen = Get-MarkupTag "td" $tbl | % { $_.Xml.'#text' }
 
    $Temp  = ([regex]‘.\d{1,2}°C’).matches($html) | foreach {$_.Groups[0].Value}
    $TempMin = ($Temp[0]).trim()
    $TempMax = ($Temp[1]).trim()
 
    # create list
    $list = @()
    $list += "Wetter"
    $list += $wetter
    $list += "Wind"
    $list += $wind
    $list += "Min"
    $list += $TempMin
    $list += "Max"
    $list += $TempMax
    $list += $regen
 
    $hash = @{} ; while ($list) { $key, $value, $list = $list; $hash[$key]=$value }
    $hash
} # End Get-Wetter
 
$res =  Get-Wetter "http://www.wetter.de/deutschland/wetter-hannover-18219670/wetterbericht-aktuell.html" 
echo "$($res.Wetter)`n$($res.Wind)." 
echo "Die Temparatur beträgt mind. $($res.Min) und höchstens $($res.Max).`nDas Regenrisiko liegt bei $($res.Regenrisiko) und die Sonne scheint etwa $($res.Sonnenstunden) Stunden."

Alle ermittelten Variablen landen schließlich in der Liste mit dem originellem Namen $list. Anschließend werden die Elemente der Liste in eine hash table „verschoben“ und als Hash zurückgegeben.

Wetter

Statt der Ausgabe als Hash gebe ich das Wetter am Ende noch schön formatiert in der Shell aus.

Geschrieben in Powershell