BottinBot4
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.
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.
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)