#!/usr/bin/env python
# -*- coding: utf-8 -*-

####################################################################
# licensed under GPL V2
# Daniel Rocher - 2008-01-01
####################################################################

# changelog
#
# * 2008-01-11 - Version 0.1.0
#	- initial release

"""Rechercher les liens morts d'un site web (erreur 404,...)
et ce, de facon recursive (limite par profondeurMax).

Usage:
	python deadlink.py http://www.mondomaine.com/

executez ce programme avec l'option --help pour connaitre toutes les options.
"""

__author__ = "Daniel Rocher (daniel.rocher@adella.org)"
__version__ = "0.1.0"
__date__ = "$Date: 2008-01-12 21:34:54 $"
__copyright__ = "Copyright (c) 2007 Daniel Rocher"
__license__ = "GPL Version 2"

import sys, getopt, os, platform
import HTMLParser
import urllib, urllib2, urlparse

####################################################################
# Variables globales :
# Ne modifier ces parametres que si vous savez ce que vous faites.

# limite la profondeur max
profondeurMax=20

# limite de recursivite (depend du systeme)
limitRecursivity=6000
sys.setrecursionlimit(limitRecursivity)

# liste d'erreurs 404 (chaine recherche dans chaque page)
# utilise seulement si 404 n'est pas envoye dans le header
listeErrors404= ["erreur 404","404 not found","page not found","error 404"]

#ignore les liens qui commence par:
listIgnoreURLstartWith=["javascript:","mailto:"]

# liste contenant les caracteres qui doivent etre supprimes dans l'URL
# tout ce qui suit cette chaine est supprime (exemple: ancres #, session Php PHPSESSID, ,...)
listofContentToBeDeleted=["#","&PHPSESSID="]


####################################################################
# initialisation

#page a analyser
baseURL=""

# profondeur atteinte
profondeur=1

# liste de tous les liens testes
listLinks=[]

# liste de liens qui ont etes analyses comme etant erronnes
listBadLinks=[]

# liste des pages qui ont etees analysees
listPages=[]

# liste de pages dont l'analyse peut etre realisee car
# ces pages ont repondues positivement a differents criteres de selection (page html, appartient au domaine,...)
listValidPages=[]

# nombre d'URL testee
urlTeste=0

# nombre d'erreurs
urlError=0

# liste de liste
listOfList=[]

# liste de page invalides (non conforme HTML)
listBadHtml=[]

# un trait (pour affichage)
trait="="*55

# si on doit enregistrer dans un fichier
saveToFile=False
# nom Fichier
fileName=""
# fichier ouvert ou ferme
fileOpened=False
# fichier
file=""
####################################################################
# 



def myPrint(line):
	"""myPrint(line)
	
	affiche 'line' et, si activee, enregistre dans un fichier
	"""
	global file,fileOpened
	print line
	if saveToFile:
		if not fileOpened:
			file=open(fileName,'w')
			fileOpened=True
		print >> file,line

def compteRendu():
	"""affiche le compte rendu"""
	myPrint("\n")
	myPrint(trait)
	myPrint(" Resume :")
	myPrint(trait)
	myPrint(" Profondeur atteinte  : %d" % profondeur)
	myPrint(" Pages analysees      : %s" % str(len(listPages)-1))
	myPrint(" URL testees          : %d" % urlTeste)
	myPrint(" Erreurs              : %d" % urlError)
	myPrint(" Pages non conforme   : %d" % len(listBadHtml)+"\n")
	myPrint(trait+"\n")
	
	myPrint("Detail des erreurs:")
	myPrint(trait)
	
	# affiche les liens eronnes
	for resu in listOfList:
		MyList=resu[1:]
		page=resu[0]
		myPrint("\n%s :" % page)
		for link in MyList:
			myPrint("\t%s" % (link))
		
	# affiche les pages non conforme
	for resu in listBadHtml:
		myPrint("\nPage non valide: %s !\n\tCette page n'a pas pu etre completement verifiee.\n\tTestez cette page avec le validateur du W3C: http://validator.w3.org/\n" % resu)
		
	myPrint("\n"+trait+"\n\n")


def addLink(link):
	"""addLink(link) -> return False si le lien est deja present dans la liste, sinon , retourne le lien
	
	ajoute le lien dans une liste (liste de toutes les URL) que s'il n'existe pas deja
	"""
	global urlTeste
	urlTeste+=1
	if not link in listLinks:
		listLinks.append(link)
		return link
	else:
		return False



def addError(currentURL,link):
	"""addError(currentURL,link)
	
	ajoute l'erreur dans la liste
	la liste contient des listes de liens. l'index 0 correspond a la page analysee.
	vient ensuite les liens errones.
	Exemple:
		[ [ page1,link1,link2,link3,link5], [page2,link3,link1], [page3,link1], [page4], ... ]
	"""
	global urlError
	# si n'y est pas deja, on ajoute a la liste des liens invalides
	if not link in listBadLinks:
		listBadLinks.append(link)

	urlError+=1
	i=0
	index=-1
	for resu in listOfList:
		if resu[0]==currentURL:
			# une liste existe deja pour cette page
			index=i
			break
		i+=1
	
	if index!=-1:
		# si la page existe deja
		# on ajoute l'URL
		myList=listOfList[index]
		myList.append(link)
		listOfList[index]=myList
		
	else:
		# si la page n'est pas dans la liste, on rajoute
		myList=[currentURL,link]
		listOfList.append(myList)



def w3cError(page):
	"""w3cError(page)
	erreur de syntaxe html, la page est non conforme aux recommendations du W3C et contient beaucoup d'erreurs
	Appelee lorsque le parser est incabable de retrouver les balises html.
	"""
	myPrint("\nPage non valide: %s !\n\tTestez cette page avec le validateur du W3C: http://validator.w3.org/\n\n" % page)
	listBadHtml.append(page)


def printCurrentURL(link,state):
	"""printCurrentURL(link,state)
	
	affiche l'URL en cours de traitement (link) ainsi que sa validitee(state)
	"""
	myPrint("\t%s %s" % (state,link))


def contient404(value):
	"""contient404(value) -> Retourn True si le texte contient une erreur 404
	
	pour les pages qui ne renverraient pas 404 dans le header
	"""
	hasError=False
	for myErreur in listeErrors404:
		if value.lower().find(myErreur)!=-1:
			hasError=True
			break
	return hasError


def coreLink(currentURL,link):
	"""coreLink(currentURL,link) -> Return False si le lien ne doit pas etre analyse sinon retourne le lien modifie
	
	Met en forme le lien
	"""
	# ignore certains liens (voir listIgnoreURLstartWith)
	ignore=False
	for ignoreURL in listIgnoreURLstartWith:
		if link.startswith(ignoreURL):
			ignore=True
			break
	if ignore:
		printCurrentURL(link,"-")
		return False

	# supprime les ancres, ... (voir liste listofContentToBeDeleted)
	for content in listofContentToBeDeleted:
		mylist=link.split(content)
		link=mylist[0]
	# complete l'URL si incomplete
	myValue=urlparse.urljoin(currentURL,link)
	# renvoi l'URL a traiter
	return myValue


def URLisInDomain(link):
	"""URLisInDomain(link) -> Return True si l'URL appartient au domaine
	
	Verifie que 'link' est dans le meme domaine que baseURL
	"""
	global baseURL
	tupleURLbase=urlparse.urlparse(baseURL)
	tupleURLlink=urlparse.urlparse(link)
	inDomain=True
	i=0
	# verifie que les adresses ont la meme racine
	for mod in tupleURLbase:
		if len(mod)==0:
			break
		# traitement du domaine (clé =1)
		if i==1:
			# teste si le domaine est identique
			if not tupleURLbase[i].endswith(tupleURLlink[i]):
				inDomain=False
				break
		else:
			# test les autres champs
			if not tupleURLlink[i].startswith(tupleURLbase[i]):
				inDomain=False
				break
		i+=1
	return inDomain


def isHTML(info):
	"""isHTML(info) -> Return True si il s'agit d'une page HTML
	
	Verifie que la page est de type: 'text/html'
	"""
	IsHtml=False
	infoSplit=str(info).split("\n")
	for line in infoSplit:
		if (line.lower().find("content-type:")!=1) and (line.lower().find("text/html")!=-1):
			IsHtml=True
			break
	return IsHtml



class parseLinks(HTMLParser.HTMLParser):
	"""definition du parser HTML
	permet de recuperer les liens (balises <a href></a>)
	"""
	def __init__(self,currentURL,currentDepth):
		global profondeur
		# Ajoute a la liste des pages analysees
		if not currentURL in listPages:
			listPages.append(currentURL)
		self.currentURL=currentURL
		self.currentDepth=currentDepth+1
		if self.currentDepth>profondeur:
			profondeur=currentDepth
		myPrint("==> Analyse de la page : %s" % currentURL)
		HTMLParser.HTMLParser.__init__(self)

	def isProfondeurMax(self):
		"""isProfondeurMax(self) -> return True si la profondeur max a etee atteinte"""
		global profondeur, profondeurMax
		return self.currentDepth>profondeurMax
		

	def isPageIsAnalysable(self,url):
		"""isPageIsAnalysable(self,url) -> Return True si la page n'a pas etee analysee et si elle peut l'etre
		
		teste si l'url est une page qui peut etre analysee (voir listValidPages et listPages)"""
		global listValidPages,listPages
		return (url in listValidPages) and (not url in listPages)

	def isBadLink(self,url):
		"""isBadLink(self,url) -> Return True si l'URL a deja ete detecte comme mauvais
		
		teste si l'url pointe vers une page invalide"""
		global listBadLinks
		return url in listBadLinks


	def analysisPage(self,url,data):
		"""analysisPage(self,url,data)
		
		analyse le contenu 'data' du la page 'url'
		"""
		# on ne lance une nouvelle instance que si la profondeur max n'a pas etee atteinte
		if self.isProfondeurMax():
			myPrint("\t ~~ Profondeur max atteinte ~~")
			return
		# creation d'une nouvelle instance
		try:
			myParser=parseLinks(url,self.currentDepth)
		except RuntimeError,err:
			myPrint("\n\nErreur d'execution: %s\n" % err)
			sys.exit(1)

		# Analyse les liens du fichier HTML (dans nouvelle instance)
		try:
			myParser.feed(data)
		# erreur d'execution
		except RuntimeError,err:
			myPrint("\n\tErreur d'execution: %s\n" % err)
			if str(err).find("recursion")!=-1:
				myPrint("\tVous pouvez augmenter la valeur dans la variable 'limitRecursivity' de ce script \n")
				myPrint("\tAttention ! la limite reelle est dependant de votre systeme\n\n")
			sys.exit(1)
		# erreur d'analyse du code HTML
		# on arrete l'analyse de cette page
		except HTMLParser.HTMLParseError:
			w3cError(url)
			return


	
	def handle_starttag(self,tag,attrs):
		"""handle_starttag(self,tag,attrs)
		
		analyse les balises
		"""
		if tag=="a":
			for name,value in attrs:
				if name=="href":
					# analyse le lien
					resuURL=coreLink(self.currentURL,value)
					if resuURL==False:
						return
					
					# si le lien a deja ete detecte mauvais auparavant, inutile de tenter l'ouverture de la page web,
					# on signale simplement que la page comporte un lien errone
					if self.isBadLink(resuURL):
						printCurrentURL(resuURL,"!")
						addError(self.currentURL,resuURL)
						return

					# si est un lien vers une page dont l'analyse est possible
					if self.isPageIsAnalysable(resuURL):
						if self.isProfondeurMax():
							return
						# on ouvre la page et on lit le contenu
						try:
							myUrl=urllib2.urlopen(resuURL)
						# normalement, ces erreurs ne devraient pas se produire car cette page a deja etee teste
						except (urllib2.HTTPError, urllib2.URLError, ValueError):
							return

						# si il s'agit d'une page web (text/html)
						if isHTML(myUrl.info()):
							myData=myUrl.read()
							printCurrentURL(resuURL,"*")
							self.analysisPage(resuURL,myData)
							return

					# ajoute si n'existe pas deja (sinon, resuURL=False)
					resuURL=addLink(resuURL)

					# si n'existe pas, on analyse le lien
					if (resuURL!=False):
						try:
							# lit l'url
							u=urllib2.urlopen(resuURL)
						except urllib2.HTTPError, exc:
							if exc.code==404:
								# erreur 404
								printCurrentURL(resuURL,"!")
								addError(self.currentURL,resuURL)
								return
							else:
								# autres erreurs (403,...)
								printCurrentURL(resuURL,"-")
								return
						except urllib2.URLError, exc:
							# le domaine n'existe pas
							printCurrentURL(resuURL,"!")
							addError(self.currentURL,resuURL)
							return
						except ValueError:
							printCurrentURL(resuURL,"!")
							addError(self.currentURL,resuURL)
							return
							
						# si il s'agit d'une page web (text/html)
						if isHTML(u.info()):
							# lit la page
							myData=u.read()
							if contient404(myData):
								# Erreur 404
								printCurrentURL(resuURL,"!")
								addError(self.currentURL,resuURL)
								return
							# a ce stade, nous savons que l'URL est valide
							printCurrentURL(resuURL,"*")
							# on n'approndie que si l'URL appartient au domaine
							if URLisInDomain(resuURL):
								# on ajoute a la liste des pages analysable
								if not resuURL in listValidPages:
									listValidPages.append(resuURL)
								# tente une analyse de la page
								self.analysisPage(resuURL,myData)
						else:
							# fichier non html
							printCurrentURL(resuURL,"b")


def usage():
	"""usage() -> affiche les options du script
	
	affichage des options du script
	"""
	print "\n\tSyntaxe: %s [OPTION]... adresse\n" % sys.argv[0]
	print "\t\t-f, --fichier <fichier>       : Enregistrer dans le fichier"
	print "\t\t-p, --profondeur <profondeur> : limiter la profondeur"
	print "\t\t-v                            : Affiche la version"
	print "\t\t-h, --help                    : Print Help (this message) and exit\n"
	print "\t\tExemple: %s http://www.mondomaine.com/" % sys.argv[0]
	print "\t\t         %s -p 10 -f debug.txt http://www.mondomaine.com/\n\n" % sys.argv[0]


# recupere arguments
args=sys.argv[1:]
try:
	opts, args = getopt.getopt(args, "hp:f:v", ["help","profondeur=","fichier="])
except getopt.GetoptError,err:
	print "\n",err
	usage()
	sys.exit(2)

# traitement des options
for opt, arg in opts:
	if opt in ("-h", "--help"):
		usage()
		sys.exit()
	elif opt in ("-v"):
		print "\nVersion du script : %s" % __version__
		print "Version de Python : %s\n" % platform.python_version()
		print
		sys.exit()
	elif opt in ("-f", "--fichier"):
		fileName=arg
		if os.path.exists(fileName):
			print "\nFichier %s existant.\n" % fileName
			sys.exit(2)
		saveToFile=True
	elif opt in ("-p", "--profondeur"):
		try:
			resu=int(arg)
			if resu>5000:
				print "\nProfondeur Max: 5000\n"
				sys.exit(2)
			profondeurMax = resu
		except ValueError,err:
			print "\nProfondeur doit etre un entier\n"
			sys.exit(2)

# on recupere dans les arguments, l'URL a traiter
if len(args)==0:
	print "\nVous devez saisir une adresse"
	usage()
	sys.exit(2)
else:
	baseURL=args[0]



# debut de l'Analyse
myPrint(trait)
myPrint("Legende :")
myPrint(" * Lien OK        b fichier non html (ignore)")
myPrint(" - lien ignore    ! lien errone")
myPrint(trait)


# creation d'une instance du parser
lParser=parseLinks("",profondeur)
lParser.handle_starttag("a",[('href',baseURL )])

# imprime le compte rendu
compteRendu()

# ferme le fichier si ouvert
if fileOpened:
	file.close()
