Webseiten scrapen mit Azure WebJobs, Node.js, jQuery, Async.js & gizp

In diesem Artikel zeige ich, wie man relativ einfach eine Seite regelmäßig mit Azure WebJobs & Node.js (jQuery bzw. cheerio) crawlen und die Daten als JSON ausgeben kann.

Im konkreten Beispiel geht es darum, wöchentlich aus dem Windows Store die aktuellen „Red Stripe Deals“-Angebote auszulesen und die Informationen als JSON-Datei auf dem Server abzulegen, wo diese z.B. von einer App weiterverwendet werden können.

Verantwortungsbewusst Crawlen & Scrapen

Ich möchte an dieser Stelle keine große Ethik-Diskussion über das scrapen von Daten vom Zaun brechen. Dazu gibt es im Web schon mehr als genug Diskussionen und Meinungen. Allerdings möchte ich anmerken, dass man selbst nur davon profitiert, wenn man nicht rücksichtslos vorgeht. Gerade wenn mehrere, unter Umständen tausende Seiten aufgerufen werden sollen, ist es durchaus im eigenen Interesse, den Server nicht mit zu vielen gleichzeitigen Requests in die Knie zu zwingen. Zudem möchte man den Seitenbetreiber ja nicht dazu ermuntern, Gegenmaßnahmen zu ergreifen.

Aus diesem Grund verwenden wir Tools wie Async.js und die in Node.js enthaltene Library zlib, um möglichst ressourcenschonend an die Daten zu kommen.

Verwendete Tools

Vorbereitung

Zunächst benötigen wir eine neue Web App, unter der wir den WebJob ausführen können. Hierfür reicht erst mal auch die kostenlose F1 Free-Instanz des App Service Plans.

azure new web app

Projekt anlegen

Um ein neues Projekt anzulegen erstellen wir einen neuen Ordner, welcher am Ende alle benötigten Files enthalten wird und den wir dann einfach als .zip auf Azure hochladen können.

In dem noch leeren Ordner öffnen wir die cmd (im Ordner Shift + Rechtsklick -> Eingabeaufforderung hier öffnen) und beginnen damit, die benötigten Node.js-Pakete herunterzuladen.

npm install request

request ist ein HTTP request client, über den wir die benötigten Seitenaufrufe ausführen können.

npm install cheerio

cheerio ist quasi eine für Node.js abgespeckte Variante von jQuery, die es ermöglicht, Informationen mit gewohnt simplen Selektoren aus dem DOM-Baum zu extrahieren.

npm install async

async ermöglicht es uns, Warteschlangen für unseren Crawler anzulegen und so die HTTP-Anfragen zu drosseln.

Neben diesen externen Paketen benötigen wir noch zwei bereits in Node.js enthaltene Module:

Mit zlib können wir die Webseiten gzip-komprimiert aufrufen und entpacken, was uns vor allem Traffic und Zeit spart.
Mit fs greifen wir am Ende auf das Dateisystem zu und speichern unsere Daten als .json-Dokument.

In unserem Stammverzeichnis legen wir nun eine leere app.js Datei an, in der wir all diese Module laden:

var request = require('request');
var cheerio = require('cheerio');
var fs = require('fs');
var async = require('async');
var zlib = require('zlib');

Anschließend brauchen wir noch ein paar Konfigurations-Variablen sowie ein output-Array, in welches wir die erhaltenen Apps pushen.

var output = [fusion_builder_container hundred_percent="yes" overflow="visible"][fusion_builder_row][fusion_builder_column type="1_1" background_position="left top" background_color="" border_size="" border_color="" border_style="solid" spacing="yes" background_image="" background_repeat="no-repeat" padding="" margin_top="0px" margin_bottom="0px" class="" id="" animation_type="" animation_speed="0.3" animation_direction="left" hide_on_mobile="no" center_content="no" min_height="none"][];
var dealsUri = "https://www.microsoft.com/en-us/store/collections/redstripedeals/pc";
var baseUri = "http://microsoft.com";
var output = [];
var outputFilename = "redstripedeals.json";
//var outputFilename = "d:\\home\\site\\wwwroot\\redstripedeals2.json";

dealsUri ist die Einstiegsseite, über die wir die Links zu den Apps erhalten.
baseUri benötigen wir, um aus den relativen Links absolute URLs bauen zu können.
Über outputFilename geben wir an, wo unsere Deals am Ende gespeichert werden. Für den lokalen Test soll die Datei einfach im Stammverzeichnis abgelegt werden. In Azure wird das Script allerdings in einem temporären Verzeichnis ausgeführt, so dass hierfür ein absoluter Pfad angegeben werden sollte.

Der Crawler

Das Script soll folgendermaßen vorgehen:

  1. Die Red-Stripe-Deals-Seite in gzip-Form mit den Links zu allen Apps herunterladen
    var loadDeals = function () {
        var options = { 
            url: dealsUri,
            port: 443,
            headers: {
                'User-Agent': 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko',
                'Accept-Language': 'en-us',
                'Content-Language': 'en-us',
    			'Accept-Encoding': 'gzip'
            },
            timeout: 0,
            encoding: null
        };
        request(options, function (err, resp, body) {
            if (err) {
                console.log("error loading page");
            } 
            if (!err) {
                if (resp.headers['content-encoding'] == 'gzip') {
                    zlib.gunzip(body, function (err, dezipped) {
                        redstripedealsParser(dezipped.toString());
                    });
                } else {
                    redstripedealsParser(body);
                }
            }
        });
    }
    
  2. Die Links mit cheerio (jQuery) extrahieren und in die Warteschlange / Queue qDetails einfügen
    var redstripedealsParser = function (body, lang)
    {
        $ = cheerio.load(body);
        $('figure h4 a').each(function ()
        {
            var appUri = baseUri + $(this).attr("href");
            qDetails.push(appUri);
        });
    }
  3. Die Warteschlange einzeln abfragen und die App-Details-Seiten entpackt and den Parser weitergeben.
    Hier lässt sich über den letzten Parameter von async.queue() die Anzahl der parallel laufenden Tasks anpassen (in diesem Fall 1).

    var qDetails = async.queue(function (task, callback) {
        console.log("get details: " + task);
    	var options = {
    		url: task,
    		headers: {
    			'User-Agent': 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko',
    			'Accept-Language': 'en-us',
    			'Content-Language': 'en-us',
    			'Accept-Encoding': 'gzip'
    		},
    		timeout: 0,
            encoding: null
    	};
    	request(options, function (err, resp, body) {
            if (err)
            {
                console.log("error: " + task);
                throw err;
            }
    		if(resp.headers['content-encoding'] == 'gzip'){
    			zlib.gunzip(body, function(err, dezipped) {
    				GetDetails(dezipped.toString(), task, callback);
    			});
    		} else {
    			GetDetails(body, task, callback);
    		}
    	}); 
    }, 1);
    
  4. App-Details mit cheerio extrahieren und in das output-Array schreiben
    Am Ende nicht den callback() für die async-Queue vergessen.

    var GetDetails = function (body, task, callback) {
        console.log("parse details: " + task);
        $ = cheerio.load(body);
    
        var app = {};
        
        app["title"] = $('#page-title').text();
        app["image"] = "http:" + $('.ph-logo > img').first().attr('src').toString();
        app["description"] = $('.showmore  > p').first().text();
        app["price"] = $('.srv_price > span').first().text();
        app["ratingValue"] = $('.srv_ratingsScore.win-rating-average').first().text();
        app["ratingCount"] = $('.win-rating-total').first().text().replace(/\D/g, '');
        app["packageSize"] = $('.metadata-list-content > div').eq(2).text().replace("\r\n", "").trim();
        app["publisher"] = $('.metadata-list-content > div').eq(0).text().replace("\r\n", "").trim();
    
        output.push(app);
        callback();
    }
  5. Wenn die Warteschlange leer ist, das output-Array als JSON-Dokument auf der Festplatte ablegen
    qDetails.drain = function ()
    {
    	fs.writeFile(outputFilename, JSON.stringify(output, null), function (err)
    	{
    		if (err)
    		{
    			console.log(err);
    		} else
    		{
    			console.log("JSON saved to " + outputFilename);
    		}
    	});
    }
  6. Script starten
    loadDeals();

Das gesamte Script findet ihr hier nochmal als Github Gist.

Das Script lässt sich über die Kommandozeile mit folgenden Befehl ausführen:

node app.js

Script veröffentlichen und Zeitplan einrichten

Sofern das Script lokal fehlerfrei durchläuft, kommentieren wir den output-Pfad für Azure ein (und den lokalen aus) und packen den ganzen Ordner anschließend als normale .zip-Datei.

Um einen WebJob „gemäß einem Zeitplan“ ausführen zu können, müssen wir uns jetzt im alten Azure Portal anmelden. Dort können wir nun in der zuvor angelegten Web App unter Webaufträge einen neuen WebJob hinzufügen. Hierzu wird lediglich ein Name und die .zip-Datei benötigt. Außerdem wird die Region abgefragt, in welcher der Job ausgeführt werden soll.

webjobs-schedule-medienstudio.net.

Im zweiten Schritt lässt sich dann planen, wann genau das Script ausgeführt werden soll. Nachdem sich in unserem Fall die Red Stripe Deals nur einmal die Woche ändern, muss der WebJob auch nicht öfters laufen.

webjobs-schedule-2-medienstudio.net

Sobald der Job eingerichtet wurde, können wir das Script zum ersten Mal ausführen. Wenn wir alles richtig gemacht haben, sollten wir nach kurzer Wartezeit ein „Success“ unter „Ergebnis der letzten Ausführung“ sehen.

WebJob Script nachträglich anpassen

Wenn wir nachträglich noch Änderungen am WebJobs Script vornehmen wollen, ist die vermutlich einfachste Variante, dies direkt via Webmatrix auf dem Server zu tun – natürlich NIE bei produktiven Systemen! 😉 Meldet man sich im Editor mit seinem Microsoft Account an, lässt sich die Web-App samt WebJobs direkt remote öffnen und editieren.

webmatrix-webjobs-medienstudio.net

JSON abrufen

Zu guter Letzt möchte man natürlich auch noch die erstellte JSON-Datei mit all den Deals in irgendeiner Weise (z.B. in einer mobilen App) weiterverarbeiten. Hier gibt es allerdings einen kleinen Stolperstein, vor dem man gewarnt sein sollte. Denn von Haus aus liefern Azure Web-Anwendungen kein statischen JSON-Dateien aus. Dies ist allerdings leicht behoben, indem man folgenden Code innerhalb der Web.config im Root-Verzeichnis ablegt:

<?xml version="1.0"?>
 
<configuration>
    <system.webServer>
        <staticContent>
            <mimeMap fileExtension=".json" mimeType="application/json" />
     </staticContent>
    </system.webServer>
</configuration>

via Microsoft Developer

Es mag auf den ersten Blick etwas viel Aufwand sein, letztendlich ist das Ganze allerdings gar nicht so tragisch. Fragen und Anmerkungen zur Anleitung und zum Code gern in den Kommentaren. 😉

 [/fusion_builder_column][/fusion_builder_row][/fusion_builder_container]

Veröffentlicht von

Thomas

Developer, Microsoft Azure MVP

Kommentar verfassen

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.