BottinBot4

De Wikipast
Sauter à la navigation Sauter à la recherche

Résumé des fonctionnalités

Le bot prend comme entrée des données dans le format csv. Chaque ligne du fichier d'entrée contient le nom, le métier et l'adresse d'une personne, ainsi que l'année à laquelle l'information a été collectée. D'autres méta-données de moindre importance sont aussi présentes. Le nom des colonnes du fichier sont les suivants:

directory,page,row,year,name,job,street,number,street_clean,street_only

L'idée principale du fonctionnement du bot est d'identifier toutes les lignes correspondant à une personne distincte, extraire celles-ci et créer ou modifier une page wikipast à partir de ces données. Une fois les données extraite, chaque ligne du fichier d'entrée devrait en théorie correspondre à une entrée dans la section Biographie de la page correspondante. Les données fournies s'étalant sur un intervalle de 10 ans, le bot devrait donc avoir la capacité de générer jusqu'à 10 entrées par personne.

Entries year.png

Distribution du nombre d'entrées par années

name               job                            street_only          number_clean   count    
Ducourtioux (Aug.) vins                           Hauteville           87               10
Lucy               notaire honoraire              Aumale               21               10
Despaux            carrossier                     Ecluses-Saint-Martin 2                 9
Lemestre           architecte                     Magenta              137               9
Marion-Carême      glacier                        Malesherbes          9                 9
Carré              notaire                        s Petits-Pères       9                 9
Neibecker          boulanger                      Saint-Martin         210               9
Lacorre (B.)       cirier                         Saint-Martin         203               9
Montigny (A.)      facteur et accordeur de pianos Monsieur-le-Prince   22                9
Rouière            tabac                          Denain               4                 9

10 entités avec le plus d'entrées

Pour qu'un tel regroupement soit possible, il faut cependant avoir la certitude que les entrées regroupées dans un même article correspondent à la même personne. Plusieurs astuces permettent d'arriver à un résultat relativement fiable.

Regroupement des entrées d'une même personne

La stratégie utilisée pour regrouper les lignes du fichier d'entrée faisant référence à la même personne est une des principales difficultés. La technique utilisée dans la version finale du bot est simplement de regrouper les lignes dont le nom, l'adresse et le métier sont identiques. Il aurait été également possible d'utiliser un critère moins conservateur et de regrouper uniquement par nom et adresse. Il semble en effet peu probable que deux personnes avec le même nom et la même adresse soient en réalité une personne différente.

Cependant, il a été décidé de garder un critère plus strict pour plusieurs raisons. Premièrement, il est difficile de distinguer deux personnes d'une même famille sans utiliser le critère du métier. Comme les membres d'une même famille partagent le même nom de famille et souvent la même adresse, le critère du métier est dans beaucoup de cas le seul critère permettant de départager ces personnes. Deuxièmement, il paraît probable que deux personnes avec le même nom de famille habitent dans le même immeuble, même si ces personnes n'ont pas de lien de parenté. Si des données complètes sur les prénoms étaient disponibles dans tous les cas, il serait probablement possible d'exploiter cette information pour obtenir plus de précision.

L'exemple suivant, tiré des données de l'annuaire, illustre la problématique du critère du nom et de l'adresse.

* 1874.01.01 / Paris, Rivoli, 68. Mention de Mazzucchi avec la catégorie diamants pour vitriers. 
* 1874.01.01 / Paris, Rivoli, 68. Mention de Mazzucchi avec la catégorie marrons en gros.
                                                                                   

Les deux lignes datant de la même années, on peut conclure avec certitude que ces deux lignes correspondent à des personnes différentes, même si l'adresse et le nom de famille sont identiques. On peut faire l'hypothèse qu'un lien de parenté unit ces deux individus.

Désambiguïsation

Lors de l'écriture de notre bot, nous avions fais en sorte que celui-ci soit capable de "merge" les informations contenues dans notre partie du bottin avec celles des autres groupes, afin de ne pas créer de pages supplémentaires pour la même entité. Lorsque nous faisions de tels tests avec nos propres pages, cela fonctionnait très bien.

Le problème qui s'est posé à nous vient du fait que le format choisi par chaque groupe est différent.

Personnes duplicate.png

Nous avons donc essayé d'extraire la sémantique des entrées, notamment en considérant les hypermots. Cette méthode s'est malheureusement soldée par un échec et nous n'avons pas réussi à merge proprement les pages existantes avec les notre, sauf cas très précis. Nous avons finalement pris le parti de créer des pages au titre le plus simple possible. Par exemple, pour un nom peu commun comme Marion-Carème, pour laquelle il n'existe pas de pages, nous avons créé une page ne contenant que son nom. En revanche, pour une entité telle que Lemestre, il était nécessaire d'ajouter le métier de la personne (car une page Lemestre existait déjà). Nous avons donc procédé de la sorte, en spécifiant le titre à chaque fois qu'une page existait déjà. Dans le cas d'une page dont le titre contiendrait toutes les informations (nom, job et adresse), et serait le même que le notre, nous ne créons pas de nouvelle page, mais "mergeons" le nouveau contenu avec celui existant.

Nous avons aussi pris en compte le fait que la page pourrait contenir des informations que nous ne considérons pas comme des entrées du bottin (ne commencent pas par la paire date/lieu). Dans ce cas, nous séparons la page en deux sections, une section biographie, au format habituel, suivi d'une seconde contenant le reste des informations n'ayant pas le format prévu

Analyse critique et améliorations envisagées

Le bot est malheureusement peu efficace pour combattre les erreurs d'OCR ou les variations dans le format des données. Une stratégie initialement envisagée était de constituer des ensembles de métiers voisins afin d'être capable de regrouper les entrées selon des critères moins stricts. Pour créer ces ensembles, une approche possible aurait été d'identifier les entrées pouvant être regroupées avec certitude en utilisant uniquement les autres critères (nom, adresse). Pour chacune de ces personnes, on obtiendrait un ensemble des différents métiers listés sur les entrées successives. Cet ensemble pourrait ainsi être réutilisé pour identifier d'autres personnes avec ce critère. Ainsi, si une personne passe de notaire à notaire honoraire, on peut en déduire que d'autres personnes pourraient connaître une évolution similaire. Cependant, comme expliqué plus haut, il est difficile d'identifier les entrées qui se rapportent à une même personne sans utiliser le critère du métier. Pour cette raison, il est difficile d'appliquer cette stratégie en pratique.

Une autre solution envisagée était de faire recours à une source externe (par exemple Wikipédia) pour identifier les métiers similaires. Cependant, le format du métier dans les données d'entrée n'est pas toujours très cohérent. Par exemple, l'activité "vins" est souvent mentionnée dans le fichier d'entrée. Comme il ne s'agit pas d'un métier à proprement parler mais plutôt d'un secteur d'activité, il devient vite difficile d'exploiter cette information.

Après exécution du bot pendant 8h, il a pu créer environ 65'000 pages.

Code du bot

from pywikiapi import Site
from datetime import *
import re
import pandas as pd
from urllib.parse import quote

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
__ROW_PATTERN = "* [[{}]] / [[{}]], {}. Mention de {} avec la catégorie {}. [{}]"
__UNK_DATE = "''(date inconnue)''"
# Dictionary required to generate URLs for entries
doc2start = {
    "bpt6k63243601": 123, 
    "bpt6k62931221": 151, 
    ...
    "bpt6k9775724t": 33, 
    "bpt6k97774838": 327, 
    "bpt6k9780089g": 339}
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
def entry2url(row):
    """
    Takes a row of an Annuaire csv and
    transforms it to the corresponding Gallica url
    """
    url = "https://gallica.bnf.fr/ark:/12148/"
    
    directory = row['directory']
    page = row['page'] - doc2start[directory]
    url += f"{row['directory']}/f{row['page']-doc2start[row['directory']]}"
    
    r_strings = []
    if 'name' in row and pd.notna(row['name']):
        r_strings.append(quote(row['name'].replace('.', ' ')))
    if 'job' in row and pd.notna(row['job']):
        r_strings.append(quote(row['job'].replace('.', ' ')))
    if 'street' in row and pd.notna(row['street']):
        r_strings.append(quote(row['street'].replace('.', ' ')))
    if 'number' in row and pd.notna(row['number']):
        r_strings.append(quote(row['number'].replace('.', ' ')))
    
    if len(r_strings) > 0:
        url += f".item.r={'%20'.join(r_strings)}.zoom"
    
    return url

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
def format_entry(entry):
    """Format an entry for wikipast

    Arguments:
        entry {pandas.core.frame.DataFrame} -- pandas data row
        section{int} -- section to which append entry
    """
    # Extracting valid fields
    e = pd.notna(entry)

    # Since we don't have a lot of fields, if any of them is missing, there is no reason to keep it.
    if not e["year"] or not e["name"] or not e["number_clean"] or not e["street_only"] or not e["job"]:
        return None

    year = entry["year"]
    name = entry["name"]
    street_number = entry["number_clean"]
    street = entry["street_only"]
    job = entry["job"]
    url = entry2url(entry)
    __ROW_PATTERN = "* [[{}]] / [[{}]], {}. Mention de {} avec la catégorie {}. [{}]"
    res = __ROW_PATTERN.format(f"{year}.01.01", "Paris", f"{street}, {street_number}".strip(), name, job, url)
    return res
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

class Bot:
    # constructor method
    def __init__(self):
        user = 'Raphael B.@G4_6000'
        password = 'fnf9033tvilqvrbmejjfpavlcn7c5isp'
        self.site = Site('http://wikipast.epfl.ch/wikipast/api.php') # Définition de l'adresse de l'API
        self.site.no_ssl = True # Désactivation du https, car pas activé sur wikipast
        self.site.login(user, password) # Login du bot 
        print("Connected to wikipast API.")

    # ----------------------------------------
    # ------------ Page Extractor ------------
    # ----------------------------------------

    def find_pages_containing(self, title):
        """Finds all the pages with the given title

        Arguments:
            title {string} -- title

        Returns:
            list[Page] -- pages that start with `title`
        """
        results = []
        for res in self.site.query(list='allpages', apprefix=title): 
            results.append(res)   
        return results
        
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_page_names_containing_exactly(self, word):
        """Gets the page names that contain exactly the given word

        Arguments:
            word {string} -- word the titles must contain

        Returns:
            list -- list of titles
        """
        results = self.find_pages_containing(word)
        pages_names = []
        for res in results[0]['allpages']: # aller dans le dictionnaire nesté
            pages_names.append(res['title'])
        return pages_names

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_page_names_containing(self, word):
        """Gets the page names that contain the given word

        Arguments:
            word {string} -- word the titles must contain

        Returns:
            list -- list of titles
        """
        res = self.find_pages_containing(word)
        pages_names = []
        for i in range(len(res)):
            for j in range(len(res[i]['allpages'])):
                pages_names.append(res[i]['allpages'][j]['title'])
        return pages_names

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_info_of_page_name_containing(self, word):
        pages_names = self.get_page_names_containing(word)
        results_pages = []
        for res in self.site.query_pages(titles=pages_names, prop=['categories', 'links', 'extlinks']): 
            results_pages.append(res)
        return results_pages

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_wiki_text(self, page, section=None):
        """Gets the raw text of the whole page or the given section

        Arguments:
            page {string} -- title of the page

        Keyword Arguments:
            section {int} -- index of the section (default: {None})

        Returns:
            string -- text of the section/page
        """
        result = self.site('parse', page=page, prop=['wikitext'], section=section)
        return result['parse']['wikitext']

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_sections(self, page):
        """Gets all the sections in a page

        Arguments:
            page {string} -- title of the page

        Returns:
            list -- list of dictionaries, each dictionary is the metadata for the section
        """
        result = self.site('parse', page=page, prop=['sections'])
        return result['parse']['sections']

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def show_page(self, title):
        """Prints a page

        Arguments:
            title {string} -- title of the page
        """
        print(self.get_wiki_text(title, section=None))
        
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_bio_entries(self, name):
        """Gets all the entries of the biography

        Arguments:
            name {string} -- title of the page

        Returns:
            list -- list of string, each string is a formatted entry
        """
        sections = self.get_sections(name)
        section_biographie_id = self.get_bio_id('title')
        
        if section_biographie_id is not None:
            wikitext = self.get_wiki_text(name, section=section_biographie_id)
            return [entry for entry in wikitext.split("\n") if entry.startswith('*')]
        
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_entries(self, title):
        """Gets all the entry of the given pages

        Arguments:
            title {string} -- title of the page

        Returns:
            list, list -- first element of tuple is dated entries, second is other entries
        """
        all_text = list()
        date_regex = r'^\* ?\[?\[?(\d{4}\.\d{1,2}\.\d{1,2})\]?\]?'
        wikitext = self.get_wiki_text(title)
        bio = list()
        other = list()
        
        for entry in wikitext.split("\n"):
            if entry != '': # empty line, not an entry
                if re.match(date_regex, entry):
                    bio.append(entry)
                else:
                    other.append(entry)
        
        return list(set(bio)), list(set(other)) # use set to eliminate duplicates

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_bio_id(self, title):
        """Gets the index of the biography section

        Arguments:
            title {string} -- title of the page

        Returns:
            int -- index of the biography in the page
        """
        sections = self.get_sections(title)
        id = None
        for section in sections:
            if section['line'] == 'Biographie':
                id = section['index']
                break
        return id
        
    # ----------------------------------------
    # ------------- Entry parser -------------
    # ----------------------------------------

    def get_keywords(self, entry):
        return re.findall("\[\[([^\[\]]*)\]\]",entry)

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_all_dates(self, entry):
        return re.findall("\[?\[?(\d{4}.\d{1,2}.\d{1,2})\]?\]?",entry)

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_all_years(self, entry):
        dates = self.get_dates(entry)
        annees = [re.findall("\d{4}",date) for date in dates]
        return flatten_list(annees)

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def get_entry_date(self, entry):
        regex = r'^\* ?\[?\[?(\d{4})\.(\d{1,2})\.(\d{1,2})\]?\]?'
        res = re.match(regex, entry)
        year = int(res.group(1))
        month = int(res.group(2))
        day = int(res.group(3))
        return date(year, month, day)

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def sort_by_date(self, entries):
        return sorted(entries, key=lambda entry: self.get_entry_date(entry))

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def flatten_list(self, l):
        return [item for sublist in l for item in sublist]

    # ----------------------------------------
    # ------------- Page creator -------------
    # ----------------------------------------

    def create_page_with_bio(self, title:str, entries:list):
        """Creates a page if it does not exist, or modifies it if it does not.
        The title must be exact.

        Arguments:
            title {string} -- title of the page
            entries {list} -- list of strings, each string is an entry of type `* [] ...`
        """
        if self.page_exists(title):
            self.modify_page(title, entries)
        else:
            self.create_biography(title,entries)
            
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def modify_page(self, title: str, entries: list):
        """Modifies the page to incorporate the given entries

        Arguments:
            title {string} -- title of the page
            entries {list} -- list of strings, each string is an entry of type `* [] ...`
        """
        bio, others = self.get_entries(title)
        bio_id = self.get_bio_id(title)
        
        if bio_id != None:
            self.site('edit', title=title,
            section=bio_id,
            text=self.entries_to_text(['== Biographie =='] + self.sort_by_date(self.clean_entries(bio + entries))),
            token=self.site.token())
            
        else:        
            self.site('edit', title=title,
            text=self.entries_to_text(others),
            token=self.site.token())
            
            self.create_biography(title,self.clean_entries(bio + entries))
        
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def create_biography(self, title, entries):
        """Creates a biography section for the article with the given title

        Arguments:
            title {string} -- title of the page
            entries {list} -- list of formatted entries
        """
        self.site('edit', title=title,
        section='new',
        sectiontitle='Biographie',
        text=self.entries_to_text(self.sort_by_date(entries)),
        token=self.site.token())

# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def page_exists(self, title):
        """Checks if a page with the exact given title already exists

        Arguments:
            title {string} -- title of the page

        Returns:
            boolean -- true if it exists, false otherwise
        """
        names = self.get_page_names_containing_exactly(title)
        
        for name in names:
            if name == title:
                return True
        
        return False
        
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def entries_to_text(self, entries):
        return "\n\n".join(entries)
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    def clean_entries(self, entries):
        entries = list(set(entries))
        return entries
# --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
# Start generation
bot = Bot()

data_bottin = pd.read_csv('./bottin_data_groupe_4.csv')
data_bottin['number_clean'] = data_bottin['number'].str.extract('(^\d+(?: ?bis)?).*')

iterator = (data_bottin
 .groupby(['name', 'job', 'street_only', 'number_clean'])
 .size().to_frame('count', )
 .sort_values(ascending=False, by='count')).iterrows()


for index, i in enumerate(iterator):
    name, job, street, number = i[0]
    pandas_rows = data_bottin[(data_bottin["name"] == name) 
                & (data_bottin["job"] == job) 
                & (data_bottin["street_only"] == street)
                & (data_bottin["number_clean"] == number)
               ]
    entries = [format_entry(row[1]) for row in pandas_rows.iterrows()]
    # Checks in multiple rounds if the pages already exists to specialize name only if necessary
    if not bot.page_exists(f"{name}"):
        title = f"{name}"
        bot.create_page_with_bio(f"{name}" ,entries)
    elif not bot.page_exists(f"{name} ({job})"):
        title = f"{name} ({job})"
        bot.create_page_with_bio(f"{name} ({job})" ,entries)
    elif not bot.page_exists(f"{name} ({job}, {street})"):
        title = f"{name} ({job}, {street})"
        bot.create_page_with_bio(f"{name} ({job}, {street})" ,entries)
    else:
        title = f"{name} ({job}, au {number} {street})"
        bot.create_page_with_bio(f"{name} ({job}, au {number} {street})" ,entries)

    if index % 50 == 0:
        print(index)