Fondamenti di Programmazione

Web Crawling

Il Web Crawling permette di visitare le pagine del Web per vari scopi. Uno degli scopi più importanti è l'effettuazione di ricerche (ad es. Google, si veda wikipedia). I programmi di questa ultima lezione sono nel pacchetto.

Il Web crawling è semplicemente la visita del grafo del Web i cui nodi sono le pagine e gli archi sono i link tra le pagine. Ovviamente, i link sono archi che hanno un verso, da una pagina verso un'altra. Si inizia da un URL, si scarica la pagina relativa all'URL e si estraggono tutti i link contenuti in essa, poi si cerca di scaricare le pagine relative a tutti i link e per ognuna si ripete la stessa procedura. Per evitare di scaricare più volte la stessa pagina, si mantiene l'insieme dei link che sono già stati scaricati.

Per implementare un Web Crawler, anche se molto semplificato, dobbiamo saper scaricare il contenuto di una pagina (tipicamente in HTML), dobbiamo saper estrarre i link da una pagina (tramite un opportuno parsing) e infine dobbiamo saper usare questi strumenti per implementare una visita del grafo del Web.

Scaricare una Pagina

Scaricare una pagina Web dato il suo URL, pur essendo un'operazione concettualmente molto semplice, dipende da parecchi dettagli che ne rendono piuttosto delicata una realizzazione soddisfacente. Per scaricare una pagina bisogna effettuare una richiesta a un server (il cui indirizzo è contenuto nell'URL). La richiesta è effettuata tramite il protocollo HTTP (HyperText Transfer Protocol, si veda ad es wikipedia) e comprende parecchie informazioni oltre all'URL, come ad esempio, il formato accettato (ad es. text/html), le codifiche del contenuto (ad es. gzip), le codifiche dei caratteri (chiamate charset, ad es. UTF-8), e molte altre. Se queste informazioni non vengono fornite nella richiesta, il server può assumere che il client, cioè, chi effettua la richiesta, accetta qualsiasi formato, codifica, ecc. la funzione urlopen() del modulo urllib2 che abbiamo già usato nella sua forma più semplice, permette di specificare anche queste informazioni aggiuntive. Basta creare un opportuno oggetto Request che "incolla" ad un URL queste informazioni sotto forma di un dizionario che rappresenta l'header della richiesta.

Ecco quindi una implementazione preliminare, chiamata appunto load_page_1(), che si accontenta di ottenere l'oggetto di tipo file che ci restituisce urlopen() e di stampare le informazioni collegate alla pagina che il server ritorna (l'attributo headers). Anche quest'ultime sono rappresentate tramite un dizionario e possiamo vederne degli esempi provando la funzione su alcuni URL.

import urllib2 as ul

HEADER = {'User-Agent':"Mozilla/5.0",
          'Accept':"text/html;q=1.0,*;q=0",
          'Accept-Encoding':"identity;q=1.0,*;q=0"}

def load_page_1(url):
    print url
    req = ul.Request(url, headers=HEADER)
    f = ul.urlopen(req, timeout=5)
    res_h = f.headers
    print res_h

Se la proviamo su alcuni URL,

load_page_1('http://www.python.org')
load_page_1('http://www.agi.it/borsa')
load_page_1('http://www.nonesiste.com')

Otteniamo:

http://www.python.org
Date: Thu, 10 Jan 2013 10:23:13 GMT
Server: Apache/2.2.16 (Debian)
Last-Modified: Thu, 10 Jan 2013 10:01:11 GMT
ETag: "105800d-4fad-4d2ec4229e7c0"
Accept-Ranges: bytes
Content-Length: 20397
Vary: Accept-Encoding
Connection: close
Content-Type: text/html

http://www.agi.it/borsa
Server: nginx
Date: Thu, 10 Jan 2013 10:28:40 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 8557
Connection: close
Cache-Control: max-age=180
Pragma: no-cache
Vary: Accept-Encoding,User-Agent
Content-Encoding: gzip
Expires: Thu, 10 Jan 2013 10:31:40 GMT

http://www.nonesiste.com
Traceback (most recent call last):
. . .
    raise HTTPError(req.get_full_url(), code, msg, hdrs, fp)
HTTPError: HTTP Error 999: AW Special Error

Notiamo che ci sono chiavi come Content-Type e Content-Encoding che però non compaiono sempre: Content-Encoding compare solo se la pagina ritornata è in forma codificata, tipicamente compressa. Il terzo URL ha prodotto un errore in quanto è inesistente. Infatti, quando urlopen() incontra un errore, e ci possono essere varie cause, solleva una specifica eccezione (Exception). Ma noi non vogliamo che la nostra funzione che scarica una pagina possa mandare in crash il programma. Per evitare ciò, Python ha un costrutto che permette di catturare le eccezioni. È il try la cui forma più semplice è la seguente:

try:
    <istruzioni che possono produrre eccezioni>
except:
    <istruzioni eseguite solo se si verifica un'eccezione>

Oppure, se vogliamo sapere quale eccezione si è verificata:

try:
    <istruzioni che possono produrre eccezioni>
except Exception as ex:  # ex e' l'oggetto relativo 
                         # all'eccezione
    <istruzioni eseguite solo se si verifica un'eccezione>

In questo modo nel blocco di except, l'oggetto ex è l'eccezione verificatesi. Il tipo Exception comprende tutti i tipi di eccezione.

Nella seconda versione load_page_2 usiamo il costrutto try e inoltre stampiamo solamente le informazioni che ci interessano che sono Content-Type e Content-Encoding:

def load_page_2(url):
    print url
    try:
        req = ul.Request(url, headers=HEADER)
        f = ul.urlopen(req, timeout=5)
        res_h = f.headers
        for k in ('Content-Type','Content-Encoding'):
            print k+':', (res_h[k] if k in res_h else None)
    except Exception as ex:
        print ex

Provandola su alcuni URL,

load_page_2('http://www.python.org')
load_page_2('http://www.agi.it/borsa')
load_page_2('http://www.nonesiste.com')
load_page_2('http://www.google.com')

Otteniamo:

http://www.python.org
Content-Type: text/html
Content-Encoding: None
http://www.agi.it/borsa
Content-Type: text/html
Content-Encoding: gzip
http://www.nonesiste.com
HTTP Error 999: AW Special Error
http://www.google.com
Content-Type: text/html; charset=UTF-8
Content-Encoding: None

Ora l'eccezione sollevata dal terzo URL è stata catturata e non manda in crash il programma.

Dobbiamo modificare la nostra funzione, perché deve ritornare la pagina scaricata e non deve stampare nulla. Inoltre, se compare la chiave Content-Encoding non vogliamo ritornare la pagina perchè il nostro Web Crawler semplificato non tratta pagine compresse. Si noti che nel header della nostra richiesta c'è 'Accept-Encoding':"identity;q=1.0,*;q=0 che significa che il nostro client non accetta alcun tipo di codifica. Nonostante questo, alcuni server inviano ugualmente pagine compresse, come nel caso dell'URL http://www.agi.it/borsa. Siccome la nostra funzione può fallire nello scaricare la pagina, a causa di errori di tipo HTTP o di codifica, conveniamo che ritorni una tripla (url, page, ex) che in url contiene l'URL richiesto, in page, se la pagina è stata scaricata senza errori, contiene la pagina in unicode (questo per rendere più agevole il successivo parsing), altrimenti sarà None, e infine ex contiene la descrizione sotto forma di stringa dell'eventuale eccezione verificatesi o None altrimenti.

def load_page_3(url):
    try:
        req = ul.Request(url, headers=HEADER)
        f = ul.urlopen(req, timeout=5)
        res_h = f.headers
        ce = 'Content-Encoding'
        if ce in res_h:
            return (url, None, ce+': '+res_h[ce])
        page = unicode(f.read(), encoding='utf-8')
        return (url, page, None)
    except Exception as ex:
        return (url, None, str(ex))

Si osservi che per la codifica in unicode abbiamo assunto che la pagina sia stata ritornata nella codifica utf-8, come vedremo presto, è un'assunzione azzardata. Sciviamo una piccola funzione di test:

import urlparse as up

LOAD_PAGE = load_page_3

def test(*urls):
    for url in urls:
        if not up.urlsplit(url).scheme: url = 'http://' + url
        url, page, ex = LOAD_PAGE(url)
        if page != None:
            print url, 'LOADED', len(page)
        else:
            print url, ex

La funzione urlsplit() del modulo urlparse suddividde un URL nelle sue componenti scheme://netloc/path;parameters?query#fragment e ognuna è ritornata in un attributo con lo stesso nome (se non è presente il valore è la stringa vuota). Qui abbiamo usato urlsplit() per determinare se lo schema è presente, se non lo è, aggiungiamo quello di default che è http. Abbiamo usato la variabile globale LOAD_PAGE per poter cambiare agevolmente la funzione da testare. Ed ecco un test:

test('www.google.org','www.agi.it/borsa','www.nonesiste.com')

che produce il risultato:

http://www.google.com LOADED 43236
http://www.agi.it/borsa Content-Encoding: gzip
http://www.nonesiste.com HTTP Error 999: AW Special Error

Ma provando test('www.nih.gov') otteniamo:

http://www.nih.gov 'utf8' codec can't decode byte 0x96 
                   in position 22617: invalid start byte

Questo significa che l'assunzione che la pagina scaricata avesse la codifica utf-8 era errata. Per rimediare dobbiamo prima di tutto tener in conto che non tutti i server dichiarano il charset usato e anche se aggiungessimo alla nostra richiesta che accettiamo solamente pagine in utf-8, molti server non rispetterebbero la richiesta. Allora, quello che possiamo fare è leggere il charset nel caso il server lo dichiari e se non lo dichiara provare le due codifiche più comuni che sono utf-8 e latin-1. Siamo così pronti per scrivere la versione finale della nostra funzione:

def load_page(url):
    try:
        req = ul.Request(url, headers=HEADER)
        f = ul.urlopen(req, timeout=5)
        res_h = f.headers
        ce = 'Content-Encoding'
        if ce in res_h:
            return (url, None, ce+': '+res_h[ce])
        charsets = ['utf-8', 'latin-1']
        if 'Content-Type' in res_h:
            ct = res_h['Content-Type'].lower()
            i = ct.find('charset=')
            if i >= 0: 
                charsets.insert(0, ct[i+len('charset='):])
        page = f.read()
        for enc in charsets:
            try:
                page = unicode(page, encoding=enc)
                break
            except: continue
        else: return (url, None, 'Encoding Error')
        return (url, page, None)
    except Exception as ex:
        return (url, None, str(ex))

Il for relativo ai charsets prova i vari tipi di codifica fino a che non ne trova uno che va bene, nel qual caso esce dal for. Se non ne trova nessuno rinuncia a decodificare la pagina e ritorna Encoding Error. Facciamo un test relativo all'URL precedente:

LOAD_PAGE = load_page

test('www.nih.gov')

E otteniamo:

http://www.nih.gov LOADED 38137

Parsing dei links

Ora che sappiamo come scaricare le pagine, possiamo passare all'estrazione dei links. Useremo il modulo html che già conosciamo. Quindi per prima cosa implementiamo una classe opportuna per il parsing con un metodo get_links() che ritrona la lista dei links. Consideriamo solamente i links contenuti nei tag a:

import html, os
import urlparse as up
from load_page import load_page

class HTMLNode(object):
    def __init__(self, tag, attr, content, closed=True):
        self.tag = tag
        self.attr = attr
        self.content = content
        self.closed = closed

    def get_links(self, d=0):
        links = []
        if (self.tag == 'a' and 'href' in self.attr and 
            self.attr['href'] != None): 
            links.append(self.attr['href'])
        if self.tag != '_text_' and d < 100:
            for c in self.content:
                links.extend(c.get_links(d + 1))
        return links

Abbiamo dovuto mettere un limite sulla profondità della ricorsione perché alcune pagine malformate producono alberi di parsing con profondità molto elevate o con dei cicli. Vediamo ora la funzione che data una pagina ritorna un insieme di link accettabili. La funzione get_links() permette di ritornare solo i link che appartengono allo stesso dominio dell'URL della pagina e/o di filtrare quelli che hanno la componente query.

def get_links(url, page, domain=False, noquery=False):
    '''Se domain e' True ritorna solo i links nello stesso
       dominio dell'URL di input. Se noquery e' True, ritorna
       solamente i links senza la componente query.'''
    try:
        parsed = html.parse(page, HTMLNode)
    except Exception as ex:
        return (None, str(ex))
    if parsed == None: 
        return (None, 'HTML Parsing Error')
    linkset = set()
    for link in parsed.get_links():
        parsed = up.urlsplit(link)
        ext = os.path.splitext(parsed.path)[1].lower()
        if (parsed.scheme.lower() in ('http','https','ftp','')
            and (parsed.netloc or parsed.path)
            and ext in ('','.htm','.html')):
            if noquery and parsed.query: continue
            if not parsed.netloc:
                link = up.urljoin(url, link)
                parsed = up.urlsplit(link)
            else:
                if domain: continue
                if not parsed.scheme:
                    link = 'http://' + link
                    parsed = up.urlsplit(link)
            linkset.add(parsed.geturl())
    return (linkset, None))

Implementiamo una funzione per effetture qualche test:

def test(*urls):
    for url in urls:
        if not up.urlsplit(url).scheme: url = 'http://' + url
        url, page, ex = load_page(url)
        if page != None:
            print url, 'LOADED', len(page)
            links, ex = get_links(url, page)
            if links != None:
                print '  Numero Links:', len(links)
            else:
                print ex
        else:
            print url, ex

test('www.python.org')

Ed ecco il risultato:

http://www.python.org LOADED 20393
  Numero Links: 72

Web Crawler

Siamo pronti per mettere tutto insieme e implementare un semplice Web Crawler. Per far sì che sia versatile e possa essere lanciato e fermato a piacimento, lo implementiamo con una classe. Così può facilmente mantenere lo stato del crawling che è l'URL di partenza (l'attributo url), l'insieme di tutti gli URL estratti (l'attributo all_links) e il sottoinsieme di quelli che devono ancora essere seguiti (l'attributo toload). In questa versione abbiamo preferito scegliere il prossimo URL da scaricare in modo random tra tutti quelli in toload. Questo per dare una maggiore varietà alle pagine scaricate. Siccome i link in toload sono mantenuti in una lista nell'ordine compatibile con la visita in ampiezza, se il prossimo link è sempre il primo della lista il crawler esegue proprio una visita in ampiezza.

import urlparse as up
import random
from load_page import load_page
from get_links import get_links

class Crawler(object):
    def __init__(self):
        '''Inizializza il web crawler'''
        self.url = None
        self.all_links, self.toload = set(), []
        self.domain, self.noquery = False, False

    def set_url(self, url):
        '''Imposta il web crawling dall'URL url, per eseguirlo
        invocare ripetutamente il metodo get_page().'''
        if not up.urlsplit(url.strip()).scheme: 
            url = 'http://' + url
        if url == self.url: return
        self.url = url
        self.all_links = set([url])
        self.toload = [url]

    def get_page(self):
        '''Effettua un passo del crawling impostato tramite
        il metodo set_url(). Se il crawling e' terminato,
        ritorna None. Se non e' terminato, ritorna una tripla 
        (url, page, err) dove url e' l'URL della pagina che ha
        tentato di scaricare (puo' essere None nel caso non
        sia ancora pronta una pagina), se questa e' stata 
        scaricata, page e' il contenuto della pagina, 
        altrimenti e' None e err e' una stringa che descrive
        l'errore che si e' verificato. Anche se la pagina e'
        stata scaricata err e' non None quando si verifica un
        errore durante il parsing dei links.'''
        toload = self.toload
        if len(toload) == 0:
            self.url = None
            return None
        i = random.randrange(0, len(toload))
        url = toload[i]
        del toload[i]
        url, page, err = load_page(url)
        if page:
            links, perr = get_links(url, page, self.domain,
                                    self.noquery)
            if links != None:
                links.difference_update(self.all_links)
                self.all_links.update(links)
                self.toload.extend(links)
            else:
                err = perr
        return (url, page, err)

    def config(self, domain=None, noquery=None):
        '''Se domain e' True segue solamente i links nello
        stesso dominio dell'URL iniziale. Se noquery e' True
        segue solamente i links senza la componente query.'''
        if domain != None: self.domain = domain
        if noquery != None: self.noquery = noquery

    def n_links(self):
        '''Ritorna il numero totale di links finora letti,
        sia gia' seguiti che ancora da seguire.'''
        return len(self.all_links)

È facile scrivere una semplice funzione che usa la classe Crawler per fare un crawling a partire da un dato URL:

def test(start_url, maxpages=10):
    if not up.urlsplit(start_url).scheme:
        start_url = 'http://' + start_url
    crawler = Crawler()
    crawler.set_url(start_url)
    print 'START CRAWLING:', start_url
    while maxpages > 0:
        p = crawler.get_page()
        if p != None:
            maxpages -= 1
            url, page, err = p
            if url:
                if page != None:
                    print 'LOADED',
                    if err != None: print err,
                    print len(page), url
                else:
                    print err, url
    print 'END CRAWLING:', start_url

Il modulo tk_crawling implementa una funzione crawling che preso in input un oggetto compatibile con i metodi della classe Crawler, fornisce una GUI che gestisce il crawling:

def test_tk():
    from tk_crawling import crawling
    crawling(Crawler())

Web Crawler Concorrente

Siccome le operazioni che accedono alla rete inoltrando richieste a server remoti perdono molto tempo aspettando le risposte dei server, la precedente implementazione della classe Crawler è inefficiente. Qualsiasi Web browser sfrutta il tempo d'attesa inoltrando altre richieste per altre pagine. Per fare qualcosa di simile dobbiamo usare necessariamente più processi che vengono eseguiti in parallelo o in modo concorrente. Ogni processo si occupa di scaricare una pagina differente. In generale, la corretta implementazione di programmi concorrenti è piuttosto complessa e delicata. Nel nostro caso però i compiti che i processi devono eseguire sono semplici e anche le loro dipendenze lo sono. La libreria di Python ha parecchi moduli che permettono di creare e gestire processi concorrenti. Noi useremo La classe Pool del modulo multiprocessing che crea e gestisce un gruppo o pool di n processi, e n può essere deciso a piacimento. Una volta creato un pool si può pianificare un compito da esegure tramite il metodo apply_async(func, args) che esegue la funzione func con input dato da args (che deve essere una tupla). Il metodo ritorna un oggetto che tra i vari metodi ha ready() che ritorna True quando la risposta del relativo compito è pronta e get() che ritorna la risposta (cioè l'output della funzione func su input args). Se si invoca get() prima che la risposta sia pronta questa blocca fino a che la risposta è pronta.

import multiprocessing as mp
import urlparse as up
import random
from load_page import load_page
from get_links import get_links

class Crawler(object):
    PROCESSES = 10

    def __init__(self):
        '''Inizializza il web crawler'''
        self.pool = mp.Pool(Crawler.PROCESSES)
        self.url = None
        self.tasks = []
        self.all_links, self.toload = set(), []
        self.domain, self.noquery = False, False

    def set_url(self, url):
        '''Imposta il web crawling dall'URL url, per eseguirlo
        invocare ripetutamente il metodo get_page().'''
        url = url.strip()
        if not up.urlsplit(url).scheme:
            url = 'http://' + url
        if url == self.url: return
        while self.tasks:
            if self.tasks[0].ready():
                del self.tasks[0]
        self.url = url
        self.all_links = set([url])
        self.toload = [url]

    def get_page(self):
        '''Effettua un passo del crawling impostato tramite
        il metodo set_url(). Se il crawling e' terminato,
        ritorna None. Se non e' terminato, ritorna una tripla 
        (url, page, err) dove url e' l'URL della pagina che ha
        tentato di scaricare (puo' essere None nel caso non
        sia ancora pronta una pagina), se questa e' stata 
        scaricata, page e' il contenuto della pagina, 
        altrimenti e' None e err e' una stringa che descrive
        l'errore che si e' verificato. Anche se la pagina e'
        stata scaricata err e' non None quando si verifica un
        errore durante il parsing dei links.'''
        toload, tasks = self.toload, self.tasks
        if len(toload) == 0 and len(tasks) == 0:
            self.url = None
            return None
        url, page, err = None, None, None
        for i in range(len(tasks)):
            if tasks[i].ready():
                url, page, err = tasks[i].get()
                del tasks[i]
                if page:
                    links, perr = get_links(url, page,
                                            self.domain,
                                            self.noquery)
                    if links != None:
                        links.difference_update(self.all_links)
                        self.all_links.update(links)
                        self.toload.extend(links)
                    else:
                        err = perr
                break
        if len(tasks) < Crawler.PROCESSES and len(toload):
            i = random.randrange(0, len(toload))
            newurl = toload[i]
            del toload[i]
            tasks.append(self.pool.apply_async(load_page,
                                               (newurl,)))
        return (url, page, err)

    def config(self, domain=None, noquery=None):
        '''Se domain e' True segue solamente i links nello
        stesso dominio dell'URL iniziale. Se noquery e' True
        segue solamente i links senza la componente query.'''
        if domain != None: self.domain = domain
        if noquery != None: self.noquery = noquery

    def n_links(self):
        '''Ritorna il numero totale di links finora letti,
        sia gia' seguiti che ancora da seguire.'''
        return len(self.all_links)

Se si prova questo crawler si noterà che la velocità è significativamente superiore.