Newer
Older
libconsulte / api.py
#! /usr/bin/python3
"""
Query the ISTEX API (ES: lucene q => json doc)
"""
__author__    = "Romain Loth"
__copyright__ = "Copyright 2014-5 INIST-CNRS (ISTEX project)"
__license__   = "LGPL"
__version__   = "0.3"
__email__     = "romain.loth@inist.fr"
__status__    = "Dev"

from json            import loads
from urllib.parse    import quote
from urllib.request  import urlopen, HTTPBasicAuthHandler, build_opener, install_opener
from urllib.error    import URLError, HTTPError
from getpass   import getpass
from os import path
from sys import stderr
from re import sub
from json import dumps
from collections import OrderedDict
from random import sample

# globals
DEFAULT_API_CONF = {
	'host'  : 'api.istex.fr',
	'route' : 'document'
}

class AuthWarning(Exception):
	def __init__(self, msg):
		self.msg = msg
	def __str__(self):
		return repr(self.msg)



# private functions
# -----------------

def _get(my_url):
	"""
	Get remote url *that contains a ~json~* 
	and parse it into an OrderedDict
	"""
	
	# print("> api._get:%s" % my_url, file=stderr)
	try:
		remote_file = urlopen(my_url)
		
	except (HTTPError, URLError) as url_e:
		# signale 401 Unauthorized ou 404 ou 400 etc
		print("api: HTTP ERR (%s) sur '%s'" % 
			(url_e.reason, my_url), file=stderr)
		# Plus d'infos: serveur, Content-Type, WWW-Authenticate..
		# print ("ERR.info(): \n %s" % url_e.info(), file=stderr)
		raise
	try:
		response = remote_file.read()
	except httplib.IncompleteRead as ir_e:
		response = ir_e.partial
		print("WARN: IncompleteRead '%s' but 'partial' content has page" 
				% my_url, file=stderr)
	remote_file.close()
	result_str = response.decode('UTF-8')
	json_values = loads(result_str, object_pairs_hook=OrderedDict)
	return json_values


def _bget(my_url, user=None, passw=None):
	"""
	Get remote auth-protected url *that contains a ~file~* 
	and pass its binary data straight from remote response
	(for instance when retrieving fulltext from ISTEX API)
	"""
	
	# /!\ attention le password est en clair ici /!\
	# print ("REGARD:", user, passw,     file=stderr)
	
	no_contents = False
	
	auth_handler = HTTPBasicAuthHandler()
	auth_handler.add_password(
			realm  = 'Authentification sur api.istex.fr',
			uri    = 'https://api.istex.fr',
			user   = user,
			passwd = passw)
	install_opener(build_opener(auth_handler))
	
	print("GET bin (user:%s)" % user, file=stderr)
	
	# contact
	try:
		remote_file = urlopen(my_url)
		
	except URLError as url_e:
		if url_e.getcode() == 401:
			raise AuthWarning("need_auth")
		else:
			# 404 à gérer *sans quitter* pour les fulltexts en nombre...
			no_contents = True
			print("api: HTTP ERR no %i (%s) sur '%s'" % 
				(url_e.getcode(),url_e.msg, my_url), file=stderr)
				# pour + de détail
				# print ("ERR.info(): \n %s" % url_e.info(),file=stderr)
	
	if no_contents:
		return None
	else:
		# lecture
		contents = remote_file.read()
		remote_file.close()
		return contents


# public functions
# ----------------
# £TODO: stockage disque sur fichier tempo si liste grande X champs nbx
def search(q, api_conf=DEFAULT_API_CONF, limit=None, n_docs=None, outfields=('title','host.issn','fulltext'), i_from=0):
	"""
	Query the API and get a (perhaps long) "hits" array of json metadata.

	args:
	-----
	   q    -- a lucene query
	                  ex: "quantum cat AND publicationDate:[1970 TO *]"

	optional kwargs:
	- - - - - - - - -
	   outfields   -- fieldNames list for the api to return for each hit 
	   limit       -- max returned hits threshold (= int)
	   api_conf    -- an inherited http config dict with these 2 keys:
	                    * api_conf['host']   <- default: "api.istex.fr"
	                    * api_conf['route']  <- default: "document"
	   n_docs      -- si on l'a déjà, la taille du pool dans l'api 
	                  (résultat de count pour la même requête)
	                  sinon, il sera recalculé pour savoir paginer
	
	   i_from      -- mode permettant de fixer &from à un entier [0;n_docs]
	               TODO actuellement entraîne forcément limit = 1 afin de
	                    ne pas interférer avec la logique de pagination
	                    des requêtes plus normales à partir de 0

	Output format is the hit list parsed from the API's json :
	[ { 'id': '21B88F4EFBA46DC85E863709CA9824DEED7B7BFC',
				  'title': 'Recovering information borne by quanta that '
						   'crossed the black hole event horizon'},
				{ 'id': 'C095E6F0A43EBE3E98E2E6E17DD8775617636034',
				  'title': 'Holographic insights and puzzles'}]
	"""
	
	# préparation requête
	url_encoded_lucene_query = my_url_quoting(q)
	
	# décompte à part
	if n_docs is None:
		n_docs = count(url_encoded_lucene_query, already_escaped=True)
	# print('%s documents trouvés' % n_docs)
	
	# construction de l'URL
	base_url = 'https:' + '//' + api_conf['host']  + '/' + api_conf['route'] + '/' + '?' + 'q=' + url_encoded_lucene_query + '&output=' + ",".join(outfields)
	# debug
	# print("api.search().base_url:", base_url)
	
	
	# limitation éventuelle fournie par le switch --maxi
	if (limit is not None) and (type(limit) == int) and (limit >= 0) and (limit < n_docs):
		n_docs = limit
	
	# la liste des résultats à renvoyer
	all_hits = []
	
	# ensuite 2 cas de figure : 1 requête ou plusieurs
	if n_docs <= 5000:
		# requête simple
		my_url = base_url + '&size=%i' % n_docs
		
		# debug
		# print("api.search()._get_url:", my_url)
		
		try:
			json_values = _get(my_url)
		except:
			raise
		all_hits = json_values['hits']
	
	else:
		# requêtes paginées pour les tailles > 5000
		print("Collecting result hits... ", file=stderr)
		for k in range(0, n_docs, 5000):
			print("%i..." % k, file=stderr)
			my_url = base_url + '&size=5000' + "&from=%i" % k
			json_values = _get(my_url)
			all_hits += json_values['hits']
		
		# TODO stocker si > RAM/5
		
		# si on avait une limite par ex 7500 et qu'on est allés jusqu'à 10000
		all_hits = all_hits[0:n_docs]
	
	return(all_hits)






# requête aléatoire
# ------------------
# ancien mode i_from de search mis dans fonction à part random_search
def random_search(q, quota = 1, nb_known_docs=None, outfields=('title','host.issn','fulltext'), api_conf=DEFAULT_API_CONF):
	"""
	Query the API for a given hit (doc => json metadata).

	args:
	-----
	   q    -- a lucene query
	                  ex: "quantum cat AND publicationDate:[1970 TO *]"
	   
	   quota -- how many hits to draw
	           (nombre de hits à piocher)
	   
	   nb_known_docs -- si on le connait déjà, le nombre de docs totaux pour cette q
	                    (permet de savoir parmi combien on va piocher)
	                    
	                    si None sera recalculé

	optional kwargs:
	- - - - - - - - -
	   outfields   -- fieldNames list for the api to return for each hit 
	   api_conf    -- an inherited http config dict with these 2 keys:
	                    * api_conf['host']   <- default: "api.istex.fr"
	                    * api_conf['route']  <- default: "document"
	
	Output format same as search()
	"""
	
	# préparation requête
	url_encoded_lucene_query = my_url_quoting(q)
	
	# décompte à part
	if nb_known_docs is None:
		nb_known_docs = count(url_encoded_lucene_query, already_escaped=True)
	# print('%s documents trouvés' % n_docs)
	
	
	# ---------------- tirage aléatoire ----------------------
		
	# range [0,1,2,3,...,total_req]
	all_indices = range(nb_known_docs)
		
	# /!\ sample() du module standard "random"
	local_tirage = sample(
	                 population=all_indices, 
	                 k=quota
	                )
	#-----------
	
	str_local_tirage = str(local_tirage)
	# ellipse
	if len(str_local_tirage) > 40:
		str_local_tirage = str_local_tirage[0:37]+'...'
	
	# pour infos
	print(" ... drawing among %i docs => %s" % (nb_known_docs, str_local_tirage), file=stderr)
	
	# ici on va lancer un par un plusieurs _get()
	# avec à chaque fois l'indice i pioché 
	# envoyé à API comme paramètre "from"
	
	# => du coup peut être un peu long
	
	# base commune de l'URL
	base_url = 'https:' + '//' + api_conf['host']  + '/' + api_conf['route'] + '/' + '?' + 'q=' + url_encoded_lucene_query + '&output=' + ",".join(outfields)
	
	# nos résultats
	random_hits = []
	
	for mon_indice in sorted(local_tirage):
		
		my_url = base_url + '&from=' + str(mon_indice) + '&size=1'
		
		# [1 json hit]
		new_hit = _get(my_url)['hits']
		
		if len(new_hit) != 1:
			raise ValueError("q=%s&from=%i vide ??" % (q, mon_indice))
		else:
			# enregistrement
			random_hits.append(new_hit.pop())
	
	return(random_hits)




def count(q, api_conf=DEFAULT_API_CONF, already_escaped=False):
	"""
	Get total hits for a lucene query on ISTEX api.
	"""
	# préparation requête
	if already_escaped:
		url_encoded_lucene_query = q
	else:
		url_encoded_lucene_query = my_url_quoting(q)
	
	# construction de l'URL
	count_url = 'https:' + '//' + api_conf['host']  + '/' + api_conf['route'] + '/' + '?' + 'q=' + url_encoded_lucene_query + '&size=1'
	
	# requête
	try:
		json_values = _get(count_url)
	except:
		raise
	
	return int(json_values['total'])


def write_fulltexts(DID, base_name=None, api_conf=DEFAULT_API_CONF, tgt_dir='.', login=None, passw=None, api_types=['fulltext/pdf', 'metadata/xml']):
	"""
	Get XML metas, TEI, PDF, ZIP fulltexts etc. for a given ISTEX-API document.
	
	"""
	# vérification
	for at in api_types:
		if at not in ['fulltext/pdf', 
						'fulltext/tei',
						'fulltext/txt',
						'fulltext/zip',
						'metadata/xml',
						'metadata/mods',
						]:
			raise KeyError("Unknown filetype %s" % at)
	
	# default name is just the ID and the fileextension
	if not base_name:
		base_name = DID
	
	# préparation requête
	da_url = 'https://'+api_conf['host']+'/'+api_conf['route']+'/'+DID
	
	for at in api_types:
			response = _bget(da_url+'/'+at, user=login, passw=passw)
			
			# _bget renvoie None pour les (rares) 404 
			#      (ex: demande tei a ecco)
			if response is not None:
				
				# ext par défaut: partie droite de la route de l'api
				ext = at.split('/')[1]
				
				tgt_path = path.join(tgt_dir, base_name+'.'+ext)
				
				fh = open(tgt_path, 'wb')
				fh.write(response)
				fh.close()


def write_fulltexts_loop_interact(list_of_ids, list_of_basenames=None, api_conf=DEFAULT_API_CONF, tgt_dir='.', api_types=['fulltext/pdf', 'metadata/xml']):
	"""
	Calls the preceding function in a loop for an entire list,
	
	With optional interactive authentification step:
	  - IF (login and passw are None AND _bget raises AuthWarning)
	    THEN ask user
	
	"""
	# test sur le premier fichier: authentification est-elle nécessaire ?
	need_auth = False
	
	first_doc_id = list_of_ids[0]
	if list_of_basenames:
		first_base_name = list_of_basenames[0]
	else:
		first_base_name = None
	
	try:
		# test with no auth credentials
		write_fulltexts(
			first_doc_id, first_base_name, 
			tgt_dir=tgt_dir,
			api_types=api_types
			)
		print("API:retrieving doc no 1 from %s" % api_types)
	except AuthWarning as e:
		print("NB: l'API veut une authentification pour les fulltexts SVP...",
				file=stderr)
		need_auth = True
	
	# récupération avec ou sans authentification
	if need_auth:
		my_login = input(' => Nom d\'utilisateur "ia": ')
		my_passw = getpass(prompt=' => Mot de passe: ')
		for i, did in enumerate(list_of_ids):
			if list_of_basenames:
				my_bname = list_of_basenames[i]
			else:
				my_bname = None
			
			print("API:retrieving doc no %s from %s" % (str(i+1),api_types))
			try:
				write_fulltexts(
					did,
					base_name = my_bname,
					tgt_dir=tgt_dir,
					login=my_login,
					passw=my_passw,
					api_types= api_types
				)
			except AuthWarning as e:
				print("authentification refusée :(")
				my_login = input(' => Nom d\'utilisateur "ia": ')
				my_passw = getpass(prompt=' => Mot de passe: ')
	
	else:
		for i, did in enumerate(list_of_ids):
			# on ne refait pas le 1er car il a marché
			if i == 0:
				continue
			if list_of_basenames:
				my_bname = list_of_basenames[i]
			else:
				my_bname = None
			print("API:retrieving doc no %s from %s" % (str(i+1),api_types))
			write_fulltexts(
				did,
				base_name=my_bname,
				tgt_dir=tgt_dir,
				api_types=api_types
			)


def terms_facet(facet_name, q="*", size_param="*", min_val=None, api_conf=DEFAULT_API_CONF):
	"""
	Get list of possible values/outcomes for a given field, with their counts (within the perimeter of the query q).
	
	output format {"facet_value_1": count_1, ...}
	
	NB: simplification de la structure
	 api: [{'docCount': 8059500, 'key': 'eng'},
	       {'docCount': 1138473, 'key': 'deu'}]
	=> ici sortie + compacte:
	    {'eng': 8059500, 'deu': 1138473 }
	
	
	attention à l'interprétation du total
	--------------------------------------
	# ici on travaille sur un nb de réponses à ma_facette
	nb_reps = sum(terms_facet("ma_facette").values())
	
	# par opposition à un nb de docs ayant la facette définie
	nb_docs = api.count(q="ma_facette:*")
	
	On aura toujours : (nb_reps >= nb_docs)
	"""
	# vérif du type taille
	if (type(size_param) != int and size_param != "*") or (type(size_param) == int and size_param < 0):
		raise TypeError("L'interrogation par facettes requiert un paramètre size int > 0 ou '*' cf. https://api.istex.fr/documentation/300-search.html#facettes")
	
	# préparation requête
	url_encoded_lucene_query = my_url_quoting(q)
	
	# construction de l'URL
	facet_url = 'https:' + '//' + api_conf['host']  + '/' + api_conf['route'] + '/' + '?' + 'q=' + url_encoded_lucene_query + '&facet=' + facet_name + '[' + size_param + ']'
	
	# requête
	json_values = _get(facet_url)
	key_counts = json_values['aggregations'][facet_name]['buckets']
	
	
	# simplification de la structure
	# [
	#  {'docCount': 8059500, 'key': 'eng'},
	#  {'docCount': 1138473, 'key': 'deu'}
	# ]
	# => sortie + compacte:
	#    {'eng': 8059500, 'deu': 1138473 }
	simpler = OrderedDict()
	if min_val == None:
		# mode classique : tous les résultats
		for record in key_counts:
			k = record['key']
			n = record['docCount']
			
			simpler[k] = n
	else:
		# mode avec filtre eg valeurs >= 2
		for record in key_counts:
			k = record['key']
			n = record['docCount']
			if n >= min_val:
				simpler[k] = n
	
	return simpler


def my_url_quoting(a_query):
	"""
	URL-escaping support with extended support to avoid
	lucene operators or API unsupported chars (afaik: none)
	
	/!\ PRÉREQUIS /!\ : 
	  les parenthèses ont 2 statuts différents selon si elles sont pour
	  la syntaxe lucene ou si c'est un contenu du fragment à matcher (token)
	   - si pour la syntaxe lucene : seront gérées ici (actuellement via quote.safe)
	   - si partie du texte à matcher => à transformer EN AMONT dans le code 'métier'
	     par exemple en wildcard '?' (car ici ce serait très dur de les reconnaître !!!)
	   
	   DONC:
	   toute '(' sera ici gardée telle quelle via quote(..safe='(') #
	   toute ')' sera ici gardée telle quelle via quote(..safe=')') #
	
	/!\ NE PAS ESCAPER UNE REQUÊTE DEUX FOIS /!\
	"""
	#print("AVANT ESCAPE:", a_query)
	
	# (1) préalables "astuces de recherches"
	# --------------------------------------
	# 1a - un '~' provenant de l'OCR voulait dire 'caractère incertain'
	#    ==> du coup on le remplace par le wildcard '?' qui veut dire 
	#        la même chose dans l'univers lucene
	a_query = sub('~', "?", a_query)
	
	# 1b - si on a un slash *dans* la requête il est un token à 
	#      matcher (contenu) mais pour garantir de ne pas interférer
	#      avec l'URL ==> aussi wildcard '?'
	a_query = sub(r'/','?', a_query)
	
	# 2 - les "%" aka '%25' posent problème (devant un chiffre?)
	# a_query = sub(r'%', '?', a_query)
	
	# (2) fonction centrale: urllib.parse.quote()
	# -------------------------------------------
	esc_query = quote(a_query, safe=":")
	
	# (3) post-traitements de validation
	# -----------------------------------
	# lucene: les jokers "?" aka '%3F' interdits en début et fin de mot
	esc_query = sub('^%3F', "", esc_query)
	esc_query = sub('%20%3F', "%20", esc_query)
	esc_query = sub('%3F%20', "%20", esc_query)
	esc_query = sub('%3F$', "", esc_query)
	
	
	
	#print("APRÈS ESCAPE:", esc_query)
	
	return esc_query


########################################################################
if __name__ == '__main__':
	
	# test de requête simple
	q = input("test d'interrogation API ISTEX (entrez une requête Lucene):")
	
	print("Vos 3 premiers matchs:")
	print(
		dumps(
			search(
				q, 
				limit=3, 
				outfields=[
					'genre',
					'host.pages.first',
					'host.title',
					'host.volume',
					'id',
					'publicationDate'
					'title',
					]
			), 
		indent=2,
		sort_keys=True,
		)
	)