Scrapy

Ernesto Rico Schmidt am 2. Februar 2018

Scrapy ist ein Framework um Daten aus Webseiten zu extrahieren.

In Bolivien gilt seit dem 21. Juli 2017 der Plan de Implementación de Software Libre y Estándares Abiertos (Implementierungsplan von Freier Software und Offenen Standards), mit dem Ziel in sieben Jahren in der öffentlichen Hand von der Nutzung von proprietäre Software, für die man Lizenzen zahlen muss, vollständig auf Freie Software zu migrieren und die Nutzung von Dokumenten in offenen Formaten zu etablieren.

Seit der Veröffentlichung des Dekret 3251, mit dem der Migrationsplan in Bewegung gesetzt worden ist, sollte z.B. bei der Beschaffung von Computern und Laptops oder bei der Vergabe von Aufträgen für die Entwicklung von Software für den Staat, oder für öffentliche Stellen, darauf geachtet werden, dass diese keine proprietäre Software voraussetzen, wie Microsoft Windows für einen Computer oder eine bestimmte Datenbank (Microsoft SQL Server) oder Programmiersprache (C#) für die zur entwickelnde Software voraussetzen.

Täglich werden hunderte Ausschreibungen publiziert. Zum Teil noch auf Papier, aber auch über die Website vom SICOES. Erstens ist die schiere Menge an Ausschreibungen ein Problem. Die Website strotzt aber auch von Javascript-Tricks, die es einfach unmöglich machen, die Daten systematisiert abzugreifen.

Scrapy

Mit Hilfe von Scrapy kann man diese Menge an täglich neue Ausschreibungen extrahieren und in einem nächsten Schritt (mit Hilfe einer Django-Applikation), entscheiden, ob ein Dokument benötigt wird, um zu entscheiden, ob diese Ausschreibung frei von oder kontaminiert mit proprietärer Software ist.

Ich verwende dafür die Website von INFOSICOES. Es ist nicht die direkte Quelle der Information, aber um einiges einfacher auf die Dokumente zu gelangen, im Vergleich zum offiziellen Weg durch die Website vom SICOES.

Als erstes installiert man, am besten in einer virtuellen Umgebung, Scrapy:

~/Projects $ mkdir  infosicoes; cd infosicoes   
~/Projects/infosicoes $ virtualenv -p python3.6 venv
~/Projects/infosicoes $ source venv/bin/activate
(venv) ~/Projects/infosicoes $ pip install Scrapy

Dann startet man ein Projekt (infosicoes), und generiert den ersten Spider (convocatorias):

(venv) ~/Projects/infosicoes $ scrapy startproject infosicoes .
(venv) ~/Projects/infosicoes $ scrapy genspider convocatorias infosicoes.com

Das erzeugt eine Reihe von Dateien, von denen zunächst uns eine interessiert: infosicoes/spiders/convocatorias.py, das ist der Spider, der wird die Information aus der Website extrahieren wird.

Ein Spider ist in diesem Fall von der Klasse scrapy.Spider abgeleitet, und definiert neben den Namen und den erlaubten Domains, die Start-URLS.

import scrapy

from datetime import datetime


class ConvcatoriasSpider(scrapy.Spider):
    name = 'convocatorias'
    allowed_domains = ['infosicoes.com']
    start_urls = [
        'https://www.infosicoes.com/contrataciones-de-bienes-bolivia.html',
        'https://www.infosicoes.com/contrataciones-de-consultoria-bolivia.html',
        'https://www.infosicoes.com/contrataciones-de-ser-generales-bolivia.html',
    ]

Die Haupt-Akteurin ist die parse-Methode, die ein response-Objekt für die Seite enthält, mit dem man die einzelnen Zeile der Tabelle extrahieren und iterieren kann. Dazu verwenden wir die css()-Methode, und suchen nach bestimmten HTML-Elementen mit bestimmten CSS-Klassen.

    def parse(self, response):
        for convocatoria in response.css('table > tr'):
            departamento = convocatoria.css('td.celda-entidad > a::text').extract_first()
            entidad = convocatoria.css('td.celda-entidad > h4 > a::text').extract_first()
            cuce = convocatoria.css('td.celda-entidad > b > a::text').extract_first()

Wenn ein Eintrag, eine Zeile der Tabelle, bis jetzt keinen CUCE (Código Único de Contrataciones del Estado) enthält, dann ist es wohl keine Ausschreibung, sondern was anderes, und wir springen zur nächsten Zeile.

            if not cuce:
                continue

Sonst machen wir weiter und holen das Objekt der Ausschreibung:

            objeto = convocatoria.css('td.celda-objeto > h2 > a::text').extract_first()

Als nächstes sind der Typ und die Modalität der Ausschreibung in einer Zelle enthalten, und wir müssen also diese aufteilen.

            tipo, modalidad = map(str.strip, convocatoria.css('td.celda-objeto::text').extract()[1].split('-'))

Manchmal ist noch mehr in einer Zeile enthalten, und wir müssen diese zunächst aufteilen und dann das Datum analysieren, wobei das Datum hier das Fomat "Tag Monat Jahr" hat. Der Status der Ausschreibung interessiert uns im Moment nicht, denn wir bekommen mit den URLs, die wir verwenden nur die aktuelle; das Datum der Veröffentlichung und das Datum der Präsentation sind dagegen interessant für uns.

            estado, publicada, presentada = list(map(str.strip, convocatoria.css('td.celda-estado::text').extract()))
            publicada = datetime.strptime(publicada.split(':')[1].strip(), '%d-%m-%Y').date()
            presentada = datetime.strptime(presentada.split(':')[1].strip(), '%d-%m-%Y').date()

Manchmal muss das Format der Zelle konvertiert werden, hier werden Punkte als Trennung der Tausender entfernt und die Kommas durch Dezimalpunkte ersetzt, damit das Format gültige Ziffern enthält.

            monto_bob, monto_usd, monto_eur = list(map(str.strip, convocatoria.css('td.celda-monto::text').extract()))
            monto_bob = float(monto_bob.split(' ')[0].replace('.', '').replace(',', '.'))
            monto_usd = float(monto_usd.split(' ')[0].replace('.', '').replace(',', '.'))
            monto_eur = float(monto_eur.split(' ')[0].replace('.', '').replace(',', '.'))

Von den verfügbaren Dokumenten zu jeder Ausschreibung ist unter Umständen der so genannte Documento Base de Contratación (D.B.C.) interessant, bei dem allerdings lediglich der Wert von arch in dem zugehörigen input-Tag notwendig ist, um später das Dokument herunter zu laden.

            contacto = convocatoria.css('td.celda-contacto::text').extract_first()
            documentos = convocatoria.css('td.celda-archivos > form > button::text').extract()
            arch = convocatoria.css('td.celda-archivos > form > input::attr(value)').extract()

            for n in range(len(documentos)):
                if documentos[n] == 'D.B.C.':
                    dbc_arch = arch[n]

Wenn jetzt alle Daten zusammen getragen worden sind, dann können wir sie als Ergebnis des Spiders für diese Zeile ausgeben.

            yield dict(
                departamento=departamento,
                entidad=entidad,
                cuce=cuce,
                objeto=objeto,
                tipo=tipo,
                modalidad=modalidad,
                publicada=publicada,
                presentada=presentada,
                monto_bob=monto_bob,
                monto_usd=monto_usd,
                monto_eur=monto_eur,
                contacto=contacto,
                arch=dbc_arch
            )

Um dann entscheiden zu können ob es weitere Seiten gibt, suchen wir nach bestimmten HTML-Elementen bzw. CSS-Klassen in der Seite, und erzeugen für jede neue Seite einen Request für diese Seite.

        current = response.css('div.box_generador > a.box_generador_espacio_actual::attr(href)').extract_first()
        other_pages = response.css('div.box_generador > a.box_generador_espacio::attr(href)').extract()

        for page in other_pages:
            if page is not current:
                next_page = response.urljoin(page)
                yield scrapy.Request(next_page, callback=self.parse)

Wenn wir jetzt den Spider aufrufen, erhalten wir die Debug-Ausgabe und die Ergebnisse. Mit der --nolog und der -o-Option wird die Debug-Ausgabe unterdrückt und alle Ausschreibungen werden in eine JSON-Datei geschrieben:

(venv) ~/Projects/infosicoes $ scrapy runspider infosicoes/spiders/convocatorias.py --nolog -o convcatorias.json

Der Spider sowie das gesamte Projekt ist übrigens auf GitHub zu finden