#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ========================================================================================
#
# Script revu et modifié par Thomas Bouchet et ljere
# auteur originel Gabriel Pettier
# license GPL V3 or later
#
# Mise sur plusieurs lignes des commandes et ajout de commentaires
# Modification utilisée pour fonctionnement sur le Topic des Lève-Tôt
#
# Description :
# Ce script permet de comptabiliser les points engrangés par les utilisateurs sur un sujet
# d'un forum suivant l'heure à laquelle ils laissent un message.
# Les résultats sont affichés régulièrement sur le sujet par un compte spécifique appelé
# "bot" (qu'il faut donc inscrire à la main pour le bon fonctionnement du script).
# Les points des différents utilisateurs sont conservés dans un fichier nommé "count"
# et "count10days"
#
# ========================================================================================
# -----------------------------------------------
# Ajout de bibliothèques utiles
# -----------------------------------------------
# BeautifulSoup est une librairie permettant d'analyser un arbre (du HTML ici)
from BeautifulSoup import BeautifulSoup
# urllib2 est une librairie permettant d'ouvrir des URLs simplement (plutôt en HTTP ici)
import urllib2
# re est une librairie permettant d'utiliser les expressions rationnelles (regular expressions)
import re
# time est une librairie de fonctions liées à la manipulation des dates
import time
# sys est une librairie permettant d'utiliser les fonctionnalités de base du langage python
import sys
# mechanize est une librairie permettant de gérer des objets HTML comme les formulaires
import mechanize
# -----------------------------------------------
# Initialisations
# -----------------------------------------------
# Ceci est une liste des utilisateurs à ne pas prendre en compte. On y mettra notamment le nom de
# l'utilisateur utilisé par le "bot"
ignoreList = (
'compteur des leve tot',
)
# -----------------------------------------------
# Class Day
#
# Cette classe permet de manipuler les utilisateurs et leurs points par jour. La classe contient
# donc les points par joueur du jour actuel.
#
# Pour des raisons propres au sujet qui nous intéresse (compteur des lève-tôt), la comptabilisation
# des points se fait entre 5 heures et 9 heures du matin exclu ([5h;9h[). Le reste ne sera donc pas
# pris en compte.
#
# Attributs :
# - entries : le tableau contenant les points de chaque joueur
#
# Méthodes spécifiques :
# - addEntry : Fonction permettant l'ajout d'un enregistrement dans le tableau des joueurs
# Cette fonction permet aussi la mise à jour du nombre de points d'un joueur déjà existant
#
# -----------------------------------------------
class Day:
# Description de la classe
"""Contient la dernière entrée (points) de ce jour pour chaque joueur"""
# Cette fonction initialise le tableau des utilisateurs lors de l'instanciation de la classe
def __init__(self):
self.entries={}
# Cette fonction permet d'ajouter un utilisateur et de le lier à son nombre de points
# On utilise : entries['utilisateur'] = nb_points
def addEntry(self, entry):
# Si l'utilisateur existe déjà, on lui attribue le nouveau nombre de moins à condition
# que celui-ci soit supérieur à l'ancien nombre de points.
# Pour des raisons de légèreté du code, il est plus simple de faire un try/except que de vérifier
# que l'entrée existe
try:
self.entries[entry.name] = max(self.entries[entry.name],entry.date.points())
except:
self.entries[entry.name] = entry.date.points()
# Cette fonction permet d'afficher la classe sous forme de chaîne de caractères.
# Ici, on affiche la liste de chaque utilisateur suivi de son nombre de points
def __str__(self):
for entry in self.entries.items():
print entry,'+',entries[entry]
# -----------------------------------------------
# Fonction utcFrance
#
# Cette fonction renvoie le décalage horaire a appliquer.
#
# Dans le cas spécifique qui nous préoccupe, le script est utilisé en France, un simple +1 suffit
# alors à déterminer le décalage horaire, sachant que le résultat renvoyé sera 1 ou 2 suivant si la machine hébergeant
# le script est en heures d'été ou pas.
#
# -----------------------------------------------
def utcFrance():
#1 + 1 si on est a l'heure d'été
return 1 + time.localtime(time.time())[-1]
# -----------------------------------------------
# Class Date
#
# Cette classe permet de calculer le nombre de points qu'un utilisateur devra recevoir suivant l'heure
# à laquelle il a écrit son message.
#
# Pour des raisons propres au sujet qui nous intéresse, un utilisateur écrivant entre :
# 5h00 et 5h59 recevra 10 points
# 6h00 et 6h59 recevra 6 points
# 7h00 et 7h59 recevra 3 points
# 8h00 et 8h59 recevra 1 point
#
# Attributs :
# - h : entier représentant une heure
# - m : entier représentant les minutes
#
# Méthodes spécifiques :
# - points : qui renvoie le nombre de points à donner pour une heure spécifique
#
# -----------------------------------------------
class Date:
# Description de la classe
# Cette fonction initialise deux entiers de la classe : h et m (hu hu hu, quel jeu de mot !)
# Si aucune donnée n'est fournie, h et m sont initialisés avec 20 et 0, et utc vaut utcFrance()
def __init__(self,tuple=(20,0),utc=utcFrance()):
# h est initialisé suivant l'heure donnée et le décalage horaire
self.h = (int(tuple[0])-utcFrance()+24+utc)%24
# m est initialisé suivant la minute donnée
self.m = int(tuple[1])
# Cette fonction permet de comparer l'objet à un autre objet équivalent
# Ici, la comparaison se fait sur le nombre de points entre les deux classes
def __cmp__(self, other):
return cmp(self.points(),other.points())
# Cette fonction permet de connaître le nombre de points à donner à un utilisateur suivant une heure spécifiée
# Si l'heure correspondante n'est pas trouvée, la fonction retourne 0
def points(self):
# Instanciation d'un dictionnaire (clé: valeur)
pts = {5: 10, 6: 6, 7: 3, 8: 1}
# Si la clé n'est pas trouvée, retourne 0
return pts.get(self.h, 0)
# -----------------------------------------------
# Class Entry
#
# Cette classe permet de lister les méta-données nécessaires de chaque message afin de calculer les points
# des utilisateurs.
#
# Pour des raisons propres au sujet qui nous intéresse
#
# Attributs :
# - name : Nom de l'utilisateur ayant laissé un message sur le forum
# - date : Date du message laissé par l'utilisateur
#
# Méthodes spécifiques :
# - setName : permet de spécifier un nom d'utilisateur
# - setDate : permet de spécifier une date
#
# -----------------------------------------------
class Entry:
# Description de la classe
# Cette fonction initialise les attributs de la classe suivant les données fournies
def __init__(self,name='',date=Date(),edit=Date()):
self.name = name
#self.date = max(date,edit)
# Contrairement au topic des couche-tard, l'édition d'un message n'est pas pris en compte pour le topic
# des lève-tôt
self.date = date
# Cette fonction permet de spécifier un nom d'utilisateur après l'instanciation de la classe
def setName(self, name):
self.name = name
# Cette fonction permet de spécifier une date après l'instanciation de la classe
def setDate(self, date):
if date.points()>self.date.points(): self.date = date
# -----------------------------------------------
# Class Score
#
# Cette classe permet de lister le classement des utilisateurs.
#
# Attributs :
# - name : Nom de l'utilisateur ayant laissé un message sur le forum
# - num : Score de l'utilisateur
#
# Méthodes spécifiques :
#
# -----------------------------------------------
class Score:
# Description de la classe
# Cette fonction initialise les attributs de la classe suivant les données fournies
def __init__(self, tuple):
self.name = tuple[1]
self.num = int(tuple[0])
# Cette fonction permet de vérifier si l'objet est plus grand qu'un autre objet équivalent
# Ici, la comparaison se fait sur num (le score de l'utilisateur)
def __gt__(self, other):
return self.num>other.num
# Cette fonction permet d'afficher la classe sous forme de chaîne de caractères.
# Ici, cela permet d'afficher le score de l'utilisateur suivi de son nom
def __str__(self):
return '%i %s' %(self.num, self.name)
# -----------------------------------------------
# Fonction getPage
#
# Cette fonction récupère la page de la discussion spécifique du forum et comptabilise les points
# des utilisateurs qui ont écrits dessus
#
# Dans le cas spécifique qui nous préoccupe, tout le code de la fonction est très spécifique
# au forum et aux css utilisés (ici, ubuntu-fr.org)
#
# -----------------------------------------------
def getPage(url, entries, stat, urlscore):
# Essaye jusqu'a 15 fois de récupérer la page
for i in range(15):
try:
# page contiendra le contenu de l'url recherchée
page = BeautifulSoup(urllib2.urlopen(url))
break
except:
print 'essai: %s' % i
# Lors de la dernière tentative, la main est rendue au gestionnaire d'erreur principal
if i==14: raise
# A chaque tentative non réussie, un délai de 60 secondes est attendu pour ne pas submerger le serveur
time.sleep(60)
# Le print permet de tracer le travail du script à condition de lancer le script à la main ou bien
# de rediriger la sortie du script vers un fichier lorsqu'on utilise un cron pour l'exécuter
print 'page récupéré, travail en cours'
# ============ ATTENTION ==================
# A partir de là, le code est très spécifique au contenu HTML des pages du forum
# Parcours de chaque élément DIV de la page contenant un message
for post in page.findAll("div","blockpost rowodd blockpost1")+page.findAll("div","blockpost roweven")+page.findAll("div","blockpost rowodd"):
# post contient donc le contenu du DIV complet
# Récupération du contenu du lien vers le message
str_date = str(post.find("h2").find("span").find("a"))
# Découpage du lien afin de supprimer le tag ancre
# On conserve la partie située après le premier caractère ">"
str_date = str_date.split('>')[1]
# On conserve la partie située avant le premier caractère "<"
str_date = str_date.split('<')[0]
# str_date contient alors quelque chose comme l'un des trois exemples ci-dessous:
# Le XX/XX/XXXX, à XX:XX
# Hier à XX:XX
# Aujourd'hui à XX:XX
# hh_mm est un tableau qui contient l'heure et la minute du message
hh_mm = str_date.split(' ')[2].split(':')
# Si str_date contient Aujourd'hui et que l'heure du message est située entre 5 et 9
if (str_date.split(' ')[0] in ["Aujourd\'hui"]
and int(hh_mm[0]) in range(5,9)
):
# On a trouvé au moins un message qui contient un score, on stocke donc l'url
urlscore = url
# Récupération du nom de l'utilisateur qui a écrit le message et création d'une
# instance de la classe Entry avec le nom récupéré
# Le nom de l'utilisateur est soit situé dans un lien (s'il a un profil), soit directement
# dans le tag strong (s'il n'a pas de profil)
try:
entry = Entry(str(post.find("div","postleft").find("a")).split(">")[1].split("<")[0])
except:
entry = Entry(str(post.find("div","postleft").find("strong")).split(">")[1].split("<")[0])
# Spécification du décalage horaire par défaut du message
utc = utcFrance()
# Si la chaine GMT est trouvée dans le message de l'utilisateur, on récupère le décalage horaire
# et on l'applique à la variable utc
if 'GMT' in str(post):
try:
utc = int(str(post).split("GMT")[-1].split(" ")[0].split("<")[0])
print 'GMT found',utc
except:
print "no good GMT!"
# La date de l'instance de la classe Entry est alors spécifiée en donnant un tableau de 2 entiers
# correspondant à l'heure et à la minute du message et le décalage horaire
entry.setDate(Date(hh_mm,utc))
# On note le nombre de messages ayant obtenu des points par heure pour effectuer des statistiques
if hh_mm[0] not in stat:
stat[hh_mm[0]] = 1
else:
stat[hh_mm[0]] += 1
# L'entrée est alors ajoutée à la liste des entrées uniquement si l'utilisateur ne doit pas être ignoré
# et si les points associés à l'entrée sont supérieurs à 0
if entry.name not in ignoreList and (entry.date.points() is not 0): entries.addEntry(entry)
# Fin du if
# Fin du parcours des messages
# Ici, on vérifie si l'on relance le script pour atteindre la page suivante, car les messages peuvent être dispersés sur plusieurs
# pages. On le fera si result vaut True, ce qui arrive uniquement si on a plusieurs pages et que la page sur laquelle on se situe
# n'est pas la dernière de la discussion
# S'il n'y a qu'une seule page dans la discussion, result vaut False
if str(page.find('p','pagelink conl')).split('conl">')[1].split('</p')[0].split(str(page.find('p','pagelink conl').find('strong'))) == ['', '']:
result = False
else:
# Sinon, on renvoie True si le numéro de la dernière page est plus grand que la page sur laquelle on est
try:
result = int(url.split('p=')[1]) < int(str(page.find('p','pagelink conl').findAll('a')[-2]).split('p=')[1].split('"')[0])
except IndexError:
result = False
# Si on est arrivé au bout de la discussion, il faut, en plus, vérifier que la discussion ne continue pas sur une autre discussion
# Ce procédé est utilisé sur le forum quand une discussion atteint trop de pages pour éclaircir le forum
if not result:
# Si la page contient la chaine "Discussion fermée" dans un paragraphe spécifique
if "Discussion fermée" in ''.join( (str( i) for i in page.findAll('p','postlink conr'))):
# Récupération du lien vers la nouvelle discussion
result = str(page.findAll('div','postmsg')[-1].findAll('a')[-1]).split('"')[1]
# Finalement, la fonction renvoie result et urlscore
return result, urlscore
# -----------------------------------------------
# Fonction renderstats
#
# Cette fonction crée deux graphiques permettant de représenter les répartitions de messages par heure pour la journée
#
# -----------------------------------------------
def renderstats(stats):
# Si le dictionnaire stats contient quelque chose
if stats != {}:
# Création d'un dictionnaire pour la journée
DayStats = {'05': 0, '06': 0, '07': 0, '08': 0, '09': 0}
# Copie des valeurs des clés existantes ce qui permet d'avoir un tableau contenant toutes les heures
DayStats.update(stats)
# ============ ATTENTION ==================
# Le reste de cette fonction n'est pas commenté car elle ne sert qu'à des fins statistiques et que j'ai autre chose à faire ;oP
HoursBar = 'h|'.join(sorted(DayStats.keys()))+'h'
HoursBar = HoursBar[20:]+'|'+HoursBar[:19]
HoursBar = HoursBar[0:len(HoursBar)-1]
HoursPie = 'h|'.join(sorted(stats.keys()))+'h'
for k in stats.keys():
HoursPie = HoursPie.replace(k+'h', k+'h%20-%20'+k+'h59')
DataBar = ','.join([str(DayStats[x]) for x in sorted(DayStats.keys())])
DataBar = ','.join(DataBar.split(',')[5:7])+','+','.join(DataBar.split(',')[7:9])
Vmax10 = str(10*(int(max([DayStats[x] for x in DayStats.keys()]))/10+1))
urlimage='[img=Répartition]http://chart.apis.google.com/chart?chs=675x280&cht=p3&chco=d80020,d88000,ffd840,20d820,2080ff,101080,a020d8&chf=bg,s,00000000&chl='+HoursPie+'&chd=t:'+','.join([str(stats[x]) for x in sorted(stats.keys())])+'&chp=1.6&chtt=R%C3%A9partition%20des%20posts&chts=606060,16[/img]'
urlimage+='[img=Posts/heure]http://chart.apis.google.com/chart?chs=675x280&cht=bvs&chxt=x,y&chds=0,'+Vmax10+'&chxr=1,0,'+Vmax10+((Vmax10 == '30' and ',5') or '')+'&chf=b0,lg,0,803000,0,ffc080,1|bg,s,00000000&chxl=0:|'+HoursBar+'h'+'&chxp=0,0.7,9.1,17.3,25.6,33.9,42&chd=t:'+DataBar+'&chm=N,803000,0,-1,12&chtt=|Nombre%20de%20posts%20par%20heure&chts=606060,16[/img]'
return urlimage
return None
# -----------------------------------------------
# Fonction post
#
# Cette fonction permet au "bot" d'écrire un message qui récapitule les scores des utilisateurs
#
# Dans le cas spécifique qui nous préoccupe, le fichier '.compteur_logins' doit contenir le login du posteur sur la première ligne,
# et son mot de passe sur la deuxième (cela et seulement cela).
#
# -----------------------------------------------
def post(_file, stats):
# Récupération de l'identifiant et du mot de passe du "bot" qui va lire, analyser, puis poster le résultat dans la discussion
file = open(".compteur_logins","r")
login = file.readline().split('\n')[0]
password = file.readline().split('\n')[0]
file.close()
# Instanciation d'un Cookie géré par la librairie mechanize
cookieJar = mechanize.CookieJar()
# Création d'un navigateur spécifique pour le "bot" et liaison avec le cookie
# Désormais, les différents appels de pages webs se feront en appui des informations de session
# conservées dans le Cookie
opener = mechanize.build_opener(mechanize.HTTPCookieProcessor(cookieJar))
opener.addheaders = [("User-agent","Mozilla/5.0 (compatible)")]
mechanize.install_opener(opener)
# Appel du formulaire de connexion
fp = mechanize.urlopen("http://forum.ubuntu-fr.org/login.php")
forms = mechanize.ParseResponse(fp)
fp.close()
# Remplissage du formulaire de connexion et validation de celui-ci
form = forms[1]
form["req_username"] = login
form["req_password"] = password
fp = mechanize.urlopen(form.click())
fp.close()
# ouverture du fichier url contenant l'adresse de la discussion à analyser
file = open('url','r')
tid = file.readline().split('=')[1][:-2] # la première ligne contenant l'addresse du topic.
file.close()
# Ouverture du formulaire permettant d'ajouter un message dans une discussion
fp = mechanize.urlopen("http://forum.ubuntu-fr.org/post.php?tid="+tid)
forms = mechanize.ParseResponse(fp)
fp.close()
# Trace des informations utilisées
print "http://forum.ubuntu-fr.org/post.php?tid="+tid
print forms[0]
form = forms[1]
# Préparation du message : Titre du message suivant l'action demandée à l'appel de la fonction
# via son paramètre _file
title = (((_file == "count") and "Scores totaux, depuis le début") or "scores de la période en cours")
# Préparation du message : Ajout de la balise [code]
# Les codes hexadécimaux utilisés à la suite de la balise code servent surement à quelque chose
form["req_message"] = title+" :[code]"
# Récupération des scores enregistrés dans le fichier correspondant
file = open(_file, 'r')
scores=file.readlines()
# Création des images statistiques si demandées
urlimage = renderstats(stats)
stats = {}
# Pour chaque score lu à partir du fichier
for i in range(len(scores)):
# La variable tmpRange contient le classement des utilisateurs par rapport à leur score
# Pour la première ligne, on initialise donc tmpRange à 0
if i == 0:
tmpRange = 0
# Si le score déjà lu est le même que celui qu'on vient de lire, la position du classement reste la même
elif scores[i].split(" ")[0] == scores[i-1].split(" ")[0]:
pass
# Sinon, on augmente la place dans le classement
else:
tmpRange = i
# Préparation du message : Ajout du classement, du score, et du nom de l'utilisateur
# C'est ici que l'on peut gérer les noms spécifiques et/ou les annonces spécifiques pour un
# utilisateur
form["req_message"] += (str(tmpRange+1)+") "+scores[i])
# Le message contient donc tous les scores, il est conclut en fermant la balise [code]
# Si les statistiques sont demandées, les images correspondantes sont rajoutées après
form["req_message"] += "[/code]"+(urlimage or '')
# Validation du formulaire
fp = mechanize.urlopen(form.click())
fp.close()
# -----------------------------------------------
# Fonction main
#
# Cette fonction est la fonction principale qui gère tout le reste
#
# Dans le cas spécifique qui nous préoccupe,
#
# -----------------------------------------------
def main(urlfile, files):
# Pour debugger le script ou pas
debug = False
# Initialisation du tableau des statistiques
stats = {}
# Ouverture du fichier contenant l'url de la discussion à analyser
f=open(urlfile,"r")
# Stockage du numéro de la discusion (topic id : tid)
url=urlscore=f.readline().split('\n')[0]
f.close()
# Instanciation de la classe Day
entries = Day()
# Boucle infinie permettant de comptabiliser les scores des utilisateurs
while True:
# Trace
print "lecture de la page "+url
# Premier comptage des points sur la dernière page visitée (la dernière fois que le script a été lancé)
res = getPage(url, entries, stats, urlscore)
# Récupération de l'url à réutiliser la prochaine fois
urlscore = res[1]
# Si la fin de discussion a été trouvée (result de getPAge vaut False), sortie de la boucle
if not res[0]: break
# Sinon, on recherche l'url de la même discussion mais page suivante à analyser
url=url.split('p=')[0]+'p='+str(1+int(url.split('p=')[1]))
# Ou bien, on repart sur l'url donnée d'une nouvelle discussion
if res[0] is not True:
url = url.split('?')[0]+'?'+res[0].split('?')[1]+'&p=1'
# Sauvegarde l'url utilisée en dernier (nouvelle page ou discussion) pour la prochaine fois
if not debug:
f=open(urlfile,"w")
f.write(urlscore+'\n')
f.close()
# Pour chaque type de travail à faire
for file in files:
# Ouverture du fichier correspondant
f=open(file,'r')
# Lecture des lignes du fichier
# Pour l'action 10days, le 2 du mois tout est remis à zéro (ce qui fait plus 1month que 10days d'ailleurs)
lines=(file=="count10days" and ((time.localtime()[2]==2 and ["0 "+entries.entries.keys()[0]+"\n"]) or f.readlines()) or f.readlines())
f.close()
# Expression régulière permettant de récupérer le score, puis le nom séparés par un nombre quelconque d'espaces
exp = re.compile("^[0-9]+\s*")
# Initialisation de la liste des scores
scores = []
print "lecture scores courants"
# Remplissage du tableau des scores ligne par ligne
for line in lines:
# Si la ligne n'est pas vide
if line not in [' ','']:
print line
# Création d'une instance de la classe Score aussitôt stockée dans le tableau scores
# et contenant le score, suivi du nom
scores.append(Score([(line.split(' ')[0]),exp.split(line)[1].split('\n')[0]]))
# Initialisation des nouveaux scores
new_scores=[]
# Remplissage du tableau des nouveaux scores et mise à jour des anciens scores suivant la liste des utilisateurs
# ayant gagné des points
for entry,num in entries.entries.items():
# Sous-boucle permettant de chercher si un utilisateur existe déjà
for score in scores:
# Comparaison des noms d'utilisateurs en casse basse
if entry.lower() == score.name.lower():
# L'utilisateur existant, on ajoute son ancien score au nouveau
score.num+=num
# Puis on quitte la sous-boucle
break
if score is scores[-1]:
# L'utilisateur n'existe pas encore, on rajoute donc son score au tableau des nouveaux scores
new_scores.append(Score([num,entry]))
break
# Les deux tableaux sont joints afin de contenir tous les scores
scores+=new_scores
# Vérification des doublons
for nScore in range(len(scores)-1):
for mScore in range(nScore+1,len(scores)-1):
try:
# En cas de doublon
if scores[nScore].name.lower() == scores[mScore].name.lower():
# les scores sont sommés
scores[nScore].num+=scores[mScore].num
# puis le doublon est détruit
del(scores[mScore])
except:
pass
# Tri des scores du plus grand au plus petit
scores.sort(reverse=True)
# Sauvegarde des scores dans le fichier correspondant
if not debug:
# Trace
for score in scores: print score
# Ecriture des scores
f=open(file, "w")
for score in scores:
f.write('%s\n'%score)
f.close()
# Essai 15 fois maximum d'écrire le message des scores
for i in range(15):
try:
post(file, stats)
stats = {}
break
except:
if i == 14: raise
time.sleep(60)
time.sleep(10)
# Lancement de la fonction principale
main("url",["count","count10days"])
# Action à lancer pour ne faire qu'une seule fonction à la main (en général pour le debug)
#post("count",{})
#post("count10days",{})