Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 6c12615
Showing
5 changed files
with
391 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
# Ignore output of scraper | ||
data.sqlite |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
This is a scraper that runs on [Morph](https://morph.io). To get started [see the documentation](https://morph.io/documentation) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
# It's easy to add more libraries or choose different versions. Any libraries | ||
# specified here will be installed and made available to your morph.io scraper. | ||
# Find out more: https://morph.io/documentation/python | ||
|
||
# Custom version of scraperwiki library | ||
#-e git+http://github.com/openaustralia/scraperwiki-python.git@morph_defaults#egg=scraperwiki | ||
|
||
scraperwiki==0.5.1 | ||
lxml==4.7.1 | ||
beautifulsoup4==4.9.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
python-3.6.2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,377 @@ | ||
# -*- coding: utf-8 -*- | ||
""" | ||
Morph.io scraper for https://www.liberliber.it/ | ||
""" | ||
|
||
import os | ||
# morph.io requires this db filename, but scraperwiki doesn't nicely | ||
# expose a way to alter this. | ||
os.environ['SCRAPERWIKI_DATABASE_NAME'] = 'sqlite:///data.sqlite' | ||
|
||
import scraperwiki | ||
from bs4 import BeautifulSoup as bs | ||
import lxml | ||
import urllib.error | ||
import re | ||
from time import sleep | ||
from random import uniform | ||
from functools import lru_cache | ||
from datetime import datetime | ||
from sqlalchemy.exc import OperationalError | ||
|
||
# Workaround for: | ||
# urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:748)> | ||
import ssl | ||
ssl._create_default_https_context = ssl._create_unverified_context | ||
|
||
|
||
try: | ||
MODE = os.environ['MORPH_MODE'] | ||
except KeyError: | ||
MODE = 'feed' | ||
|
||
try: | ||
USER_AGENT = os.environ['MORPH_USER_AGENT'] | ||
except KeyError: | ||
USER_AGENT = 'Mozilla/5.0 (X11; Linux i686; rv:64.0) Gecko/20100101 Firefox/64.0' | ||
|
||
|
||
def log(*args): | ||
""" | ||
Semplicissima funzione per log a schermo con data e ora. Placeholder se | ||
servisse fare qualcosa di più complesso in futuro. | ||
""" | ||
print(datetime.now(), *args) | ||
|
||
|
||
def soupify(url, bs_parser='html.parser'): | ||
""" | ||
Scarica un url e restituisce un oggetto BeautifulSoup. | ||
""" | ||
sleep(round(uniform(1, 3), 2)) # Aspetta un po' per non stressare il server | ||
try: | ||
content = scraperwiki.scrape(url, None, USER_AGENT) | ||
except urllib.error.HTTPError as e: | ||
log(e, 'on', url) | ||
# Cosa restituiamo? | ||
else: | ||
return bs(content, bs_parser) | ||
|
||
|
||
def id_from_soup(soup): | ||
""" | ||
Restituisce l'id della pagina a partire da un oggetto BeautifulSoup. | ||
""" | ||
id = soup.find('link', rel='shortlink')['href'].replace('https://www.liberliber.it/online/?p=', '') | ||
return int(id) | ||
|
||
|
||
@lru_cache(maxsize=128) # Non serve dimensione elevata perché autori sono processati in sequenza | ||
def get_author_id(url): | ||
""" | ||
Cerca di recuperare l'id dell'autore nel db a partire dall'url della scheda. | ||
Se qualsiasi cosa va storta apre l'url e recupera l'id da lì. Usa lru_cache. | ||
""" | ||
try: | ||
id = scraperwiki.sql.select(f'id FROM autori WHERE url IS "{url}"')[0]['id'] | ||
except: | ||
soup = soupify(url) | ||
id = scrape_author_data(soup) | ||
|
||
return id | ||
|
||
|
||
def scrape_url(url): | ||
""" | ||
Determina se l'url è relativo a una lettera, un autore o un'opera ed esegue | ||
la relativa funzione. Utilizzato con MODE url=... per recuperare pagine | ||
specifiche. | ||
""" | ||
l = len(url.replace('https://www.liberliber.it/', '').rstrip('/').split('/')) | ||
# Lettera es. URL: | ||
# https://www.liberliber.it/online/autori/autori-a/ | ||
# 1 2 3 | ||
if l == 3: | ||
scrape_letter(url) | ||
# Autore es. URL: | ||
# https://www.liberliber.it/online/autori/autori-a/antonio-abati/ | ||
# 1 2 3 4 | ||
elif l == 4: | ||
scrape_author(url) | ||
# Opera es. URL: | ||
# https://www.liberliber.it/online/autori/autori-a/antonio-abati/delle-frascherie-di-antonio-abati-fasci-tre/ | ||
# 1 2 3 4 5 | ||
elif l == 5: | ||
scrape_book(url) | ||
else: | ||
log("Can't identify URL", url) | ||
|
||
|
||
def scrape_letter(url): | ||
# Es. URL: https://www.liberliber.it/online/autori/autori-a/ | ||
log('Scraping URL:', url) | ||
soup = soupify(url) | ||
anchors = soup.select('div.post-content ul li a') | ||
for i in anchors: | ||
log('Scraping author:', i.get_text()) | ||
scrape_author(i['href']) | ||
|
||
|
||
def scrape_author(url): | ||
""" | ||
Cerca se sono presenti nella pagina opere dell'autore e nel caso raccoglie | ||
i dati sull'autore e i link delle opere sui quali esegue scrape_book(). | ||
""" | ||
# Es. URL https://www.liberliber.it/online/autori/autori-a/antonio-abati/ | ||
soup = soupify(url) | ||
# Cerca se sono presenti nella pagina opere dell'autore | ||
# li.ll_autore_elenco_libro esclude la musica che ha come classe | ||
# ll_autore_elenco_musica | ||
anchors = soup.select('li.ll_autore_elenco_libro span.ll_autore_elenco_opera_titolo a') | ||
# Se non ci sono (lista vuota) ci fermiamo qui... | ||
if not anchors: | ||
log('No books for this author found at', url) | ||
return | ||
# ...altrimenti raccoglie i dati sull'autore | ||
if scrape_author_data(soup) is None: | ||
return # Non sono stati trovati dati sull'autore. Ci fermiamo qui. | ||
# adesso raccogliamo i dati sulle opere | ||
for i in anchors: | ||
scrape_book(i['href']) | ||
|
||
|
||
def scrape_author_data(soup): | ||
""" | ||
Cerca nella pagina le informazioni su nome autore, ordinamento ed elenco | ||
e crea una voce nella tabella autori. Restituisce l'id dell'autore (utile | ||
in get_author_id() oppure None se non sono stati trovati dati nella pagina | ||
es. Autore anonimo). | ||
""" | ||
id = id_from_soup(soup) | ||
|
||
# L'URL va preso all'interno della pagina perché alla funzione passiamo il | ||
# Beautifulsoup Object | ||
url = soup.find('meta', property='og:url')['content'] | ||
|
||
record = {'id' : id, 'url' : url} | ||
for item in ['autore', 'ordinamento', 'elenco']: | ||
divs = soup.select('div.ll_metadati_etichetta:-soup-contains("' + item + ':") + div.ll_metadati_dato') | ||
if divs: | ||
record[item] = divs[0].get_text() | ||
|
||
# Non processiamo le schede autore vuote Es. | ||
# https://www.liberliber.it/online/autori/autori-a/autore-anonimo/ | ||
# Attenzione: le opere degli autori anonimi non vanno cercate qui. | ||
if len(record) < 3: | ||
log('No author data found at:', url) | ||
return | ||
|
||
scraperwiki.sql.save(unique_keys=['id'], data=record, table_name='autori') | ||
return id | ||
|
||
|
||
def scrape_book(url): | ||
""" | ||
Cerca nella pagina le informazioni sull'opera e i file associati. | ||
""" | ||
# Es. URL https://www.liberliber.it/online/autori/autori-a/antonio-abati/delle-frascherie-di-antonio-abati-fasci-tre/ | ||
soup = soupify(url) | ||
|
||
# Prima verifichiamo che ci sono libri associati all'opera | ||
# anchors = soup.select('div.post-content div.ll_opera_riga:-soup-contains("Scarica gratis") ~ a') | ||
# Escludiamo link a yeerida e audiolibri => :has(:not(img.ll_ebook_epub_yeerida,img.redirect_libroparlato) | ||
anchors = soup.select('div.post-content div.ll_opera_riga:-soup-contains("Scarica gratis") ~ a:has(:not(img.ll_ebook_epub_yeerida,img.redirect_libroparlato))') | ||
# Se non ci sono (lista vuota) non andiamo oltre... | ||
if not anchors: | ||
log('No books at:', url) | ||
return | ||
|
||
# ...altrimenti raccogliamo le informazioni sull'opera | ||
id = id_from_soup(soup) | ||
record = {'id' : id, 'url' : url} | ||
|
||
headers = [ | ||
u'titolo', | ||
u'sottotitolo', | ||
u'titolo per ordinamento', | ||
u'autore', | ||
u'descrizione breve', | ||
u'opera di riferimento', | ||
u'licenza', | ||
u'cura', | ||
u'data pubblicazione', | ||
u'opera elenco', | ||
u'ISBN', | ||
u'soggetto BISAC', | ||
u'affidabilità', | ||
u'impaginazione', | ||
u'pubblicazione', | ||
u'revisione', | ||
u'traduzione', | ||
#u'album', | ||
#u'artista', | ||
#u'etichetta', | ||
#u'genere', | ||
#u'tipo registrazione', | ||
] | ||
|
||
for i in headers: | ||
for j in soup.select(u'div.ll_metadati_etichetta:-soup-contains("' + i + u':")'): | ||
if j.get_text() == 'soggetto BISAC:': | ||
for l in filter(None, re.split('([A-Z, ]+ / .*?[a-z](?=[A-Z]))', j.next_sibling.get_text())): | ||
make_bisac(id, l) | ||
elif j.get_text() == 'autore:': | ||
# Del campo autore non ci serve il testo, ma il link che dopo useremo per recuperare l'id | ||
# da inserire in record['autore'] | ||
try: | ||
auth_url = j.next_sibling.find('a')['href'] | ||
# ATTENZIONE! Atlante universale di geografia antica e moderna non ha link nel campo autore | ||
# https://www.liberliber.it/online/autori/autori-a/atlante-universale-di-geografia-antica-e-moderna/atlante-universale-di-geografia-antica-e-moderna/ | ||
# Soluzione sporchissima: prendiamo l'url dell'autore partendo dall'url dell'opera sperando | ||
# che Liber Liber non cambi metodo di denominazione delle pagine. | ||
except TypeError: # se j.next_sibling.find('a') restituisce None non ha ['href'] | ||
auth_url = url[:url.rstrip('/').rfind('/')] | ||
elif j.get_text() == i + ':': | ||
record[i] = j.next_sibling.get_text() | ||
|
||
# Non processiamo oltre le schede relative al traduttore o altri rimandi. Es. | ||
# https://www.liberliber.it/online/autori/autori-a/vittorio-alfieri/la-guerra-di-catilina-la-guerra-di-giugurta/ | ||
# Vittorio Alfieri ha tradotto “La guerra di Catilina” e “La guerra di Giugurta” di Gaius Sallustius Crispus in italiano. | ||
if len(record) < 3: | ||
log('No book data found at:', url) | ||
return | ||
|
||
record['autore_id'] = get_author_id(auth_url) | ||
scraperwiki.sql.save(unique_keys=['id'], data=record, table_name='opere') | ||
|
||
# Poi i file associati all'opera | ||
for idx, item in enumerate(anchors): | ||
img = item.find('img') | ||
record = { | ||
'id' : f'{id}-{idx}', | ||
'opera_id' : id, | ||
'formato' : img['alt'].lower(), | ||
'url' : item['href'], | ||
} | ||
scraperwiki.sql.save(unique_keys=['id'], data=record, table_name='file') | ||
|
||
# File musicali | ||
# for idx, item in enumerate(soup.select('ul.ll_musica_elenco_mp3 li a, ul.ll_musica_elenco_ogg li a')): | ||
# record = { | ||
# 'id' : f'{id}-m{idx}', | ||
# 'opera_id' : id, | ||
# 'brano' : item.get_text(), | ||
# 'formato' : item['href'][-3:], | ||
# 'url' : item['href'], | ||
# } | ||
# scraperwiki.sql.save(unique_keys=['id'], data=record, table_name='file') | ||
|
||
|
||
def make_bisac(book_id, bisac): | ||
""" | ||
Crea una voce di relazione tra book_id e bisac. Se la stringa bisac non è | ||
presente nel database crea la relativa voce. | ||
""" | ||
|
||
# cerchiamo se la stringa bisac è gia presente nel db | ||
try: | ||
bisac_id = scraperwiki.sql.select(f'id as n FROM bisac WHERE bisac IS "{bisac}"')[0]['n'] | ||
except (OperationalError, IndexError) as e: | ||
# se non esiste ancora il db o la tabella impostiamo a 0 l'indice per | ||
# la voce nella tabella bisac | ||
if type(e) == OperationalError: | ||
bisac_id = 0 | ||
# se la stringa bisac non è ancora presente in tabella (scraperwiki.sql.select | ||
# ha restituito una lista vuota) contiamo quante righe ci sono nella tabella | ||
# bisac per sapere quale id dobbiamo dare alla nuova voce | ||
elif type(e) == IndexError: | ||
bisac_id = scraperwiki.sql.select('COUNT(id) as n FROM bisac')[0]['n'] | ||
|
||
# quindi salviamo la nuova voce bisac | ||
record = { | ||
'id' : bisac_id, | ||
'bisac' : bisac, | ||
} | ||
scraperwiki.sql.save(unique_keys=['id'], data=record, table_name='bisac') | ||
|
||
|
||
# contiamo quante righe ci sono nella tabella di relazione per sapere quale | ||
# id dobbiamo dare alla nuova voce | ||
try: | ||
rel_id = scraperwiki.sql.select('COUNT(id) as n FROM rel_bisac_opere')[0]['n'] | ||
except OperationalError: | ||
# non esiste ancora il db o la tabella: impostiamo a 0 l'indice | ||
rel_id = 0 | ||
|
||
# salviamo la voce di associazione per rel_bisac_opere | ||
record = { | ||
'id' : rel_id, | ||
'bisac_id' : bisac_id, | ||
'opera_id' : book_id, | ||
} | ||
scraperwiki.sql.save(unique_keys=['id'], data=record, table_name='rel_bisac_opere') | ||
|
||
|
||
def build_db(letters='#ABCDEFGHIJKLMNOPQRSTUVWXYZ'): | ||
""" | ||
Funzione per la costituzione iniziale del database. Esegue scrape_letter() | ||
per ciascuna lettera fornita come parametro (su tutte le lettere se non si | ||
indica il parametro). | ||
""" | ||
for i in letters: | ||
scrape_letter('https://www.liberliber.it/online/autori/autori-' + i) | ||
|
||
|
||
def parse_feed(url='https://www.liberliber.it/online/feed/'): | ||
""" | ||
Analizza il feed Atom di Liber Liber e cerca nei nuovi post link alle schede | ||
di opere da inserire nel database. Se li trova esegue scrape_book() su di | ||
essi. Salva nel database l'id dell'ultimo post analizzato. Da utilizzare per | ||
l'aggiornamento quotidiano del database. | ||
""" | ||
|
||
# scraperwiki.sql.save_var doesn't seem to work so... | ||
# last_postId = scraperwiki.sql.get_var('lastpost') | ||
try: | ||
q = scraperwiki.sql.select('value FROM myvar WHERE name IS "lastpost"') | ||
except OperationalError: | ||
last_postId = 0 | ||
else: | ||
last_postId = q[0]['value'] | ||
log('last_postId is', last_postId) | ||
|
||
tmp_postId = last_postId | ||
soup = soupify(url, 'lxml') | ||
|
||
items = soup.find_all('item') | ||
for i in items: | ||
postId = int(i.find('post-id').get_text()) | ||
if postId > last_postId: | ||
link = i.find('p', class_='ll_dl').find('a')['href'] | ||
log('Found', link) | ||
scrape_book(link) | ||
|
||
if postId > tmp_postId: | ||
tmp_postId = postId | ||
|
||
if tmp_postId > last_postId: | ||
# scraperwiki.sql.save_var doesn't seem to work so... | ||
# scraperwiki.sql.save_var('last_pubDate', tmp_postId) | ||
scraperwiki.sql.save(unique_keys=['name'], data={'name' : 'lastpost', 'value' : tmp_postId}, table_name='myvar') | ||
|
||
|
||
def main(): | ||
if MODE == 'feed': | ||
parse_feed() | ||
elif MODE.startswith('url='): | ||
for i in MODE[4:].split(','): | ||
print(i) | ||
scrape_url(i) | ||
elif MODE.startswith('build='): | ||
build_db(MODE[6:]) | ||
else: | ||
build_db() | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |