Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
slow-mo committed Jan 31, 2022
0 parents commit 6c12615
Show file tree
Hide file tree
Showing 5 changed files with 391 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
# Ignore output of scraper
data.sqlite
1 change: 1 addition & 0 deletions README.md
@@ -0,0 +1 @@
This is a scraper that runs on [Morph](https://morph.io). To get started [see the documentation](https://morph.io/documentation)
10 changes: 10 additions & 0 deletions requirements.txt
@@ -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
1 change: 1 addition & 0 deletions runtime.txt
@@ -0,0 +1 @@
python-3.6.2
377 changes: 377 additions & 0 deletions scraper.py
@@ -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()

0 comments on commit 6c12615

Please sign in to comment.