diff --git a/openalex-dumps/.gitignore b/openalex-dumps/.gitignore new file mode 100644 index 0000000..d3f7d85 --- /dev/null +++ b/openalex-dumps/.gitignore @@ -0,0 +1,3 @@ +02-download/ +03-enrich/ +04-report/ diff --git a/openalex-dumps/01-query/test01.txt b/openalex-dumps/01-query/test01.txt new file mode 100644 index 0000000..bc5c6ac --- /dev/null +++ b/openalex-dumps/01-query/test01.txt @@ -0,0 +1,2 @@ +authorships.author.id:a5000387389 + diff --git a/openalex-dumps/02-download.ini b/openalex-dumps/02-download.ini new file mode 100644 index 0000000..3726ffe --- /dev/null +++ b/openalex-dumps/02-download.ini @@ -0,0 +1,51 @@ +append = pack + +[use] +plugin = basics +plugin = conditor +plugin = analytics + +[TXTConcat] + +[env] +; [G] to get more about mailto see https://docs.openalex.org/how-to-use-the-api/rate-limits-and-authentication;the-polite-pool +path = mailto +value = env('mail', 'you@example.com') + +; before change see https://docs.openalex.org/how-to-use-the-api/get-lists-of-entities/paging +path = per-page +value = 200 + +; before change see https://docs.openalex.org/api-entities/works +path = url +value = https://api.openalex.org/works + +path = query +value = self().trim() + +[replace] +path = filter +value = env('query') + +path = per-page +value = env('per-page') + +path = mailto +value = env('mailto') + +[OAFetch] +timeout = 60000 +retries = 1 + +; Add contextual metadata related to the import +[assign] +path = lodexStamp.importedDate +value = fix(new Date()).thru(d => new Intl.DateTimeFormat('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }).format(d)) +path = lodexStamp.usedParser +value = env('parser') +path = lodexStamp.query +value = env('query') +; si la requete contient un identifiant, on récupère ici l'identifiant contenu dans la requête afin de l'utiliser comme valeur dynamique dans [A] et [E] +path = lodexStamp.queryIdentifier +value = env('query').thru(string => string.match(/i\d+/)).toUpper() + diff --git a/openalex-dumps/03-enrich.ini b/openalex-dumps/03-enrich.ini new file mode 100644 index 0000000..3c3f1e6 --- /dev/null +++ b/openalex-dumps/03-enrich.ini @@ -0,0 +1,186 @@ +prepend = unpack +append = pack +; On retire les notices non pertinentes en raison d'erreurs d'affiliations d'OpenAlex. +; Exemple pour le laboratoire GANIL +;[A] On récupère dans un premier temps les adresses originales qu'OpenALex a identifié comme relevant du laboratoire requêté. +[assign] +path = filtered_raw_affiliation_strings +value = get("authorships").filter(obj => obj.institutions.some(obj => obj.id === `https://openalex.org/${self.lodexStamp.queryIdentifier}`)).flatMap(obj => obj.raw_affiliation_strings) + +;[B] Dans un second temps on teste des regex pour vérifier que les adresses originales matchent bien avec le laboratoire requêté. +;[assign] +;path = filtered_and_tested_raw_affiliation_strings +;value = get("filtered_raw_affiliation_strings").some(item => /ganil/i.test(item) || /grand acc.*national.*ions.*lour/i.test(item) || /Large heavy ion Nat.*acc.*/i.test(item)) + +;[C] Enfin on supprime les notices non pertinentes. +;[remove] +;test = get("filtered_and_tested_raw_affiliation_strings").isEqual(false) +; }}} +[assign] +; [D] +path = uri +value = get('id').replace('https://openalex.org/', '') + +path = doi +value = get('doi').replace("https://doi.org/","").toLower() + +path = is_oa +value = get("open_access.is_oa").thru(bool=>bool === false ?"Non" : "Oui") + +path = oa_status +value = get("open_access.oa_status").capitalize() + +path = keywords +value = get('keywords').map('display_name') + +path = author_name +value = get('authorships').map("author.display_name") + +; [E] On récupère uniquement les auteurs du laboratoire requêté. +path = filtered_author_name +value = get("authorships").filter(obj => obj.institutions.some(obj => obj.id === `https://openalex.org/${self.lodexStamp.queryIdentifier}`)).flatMap(obj => obj.author.display_name) + +path = institutions +value = get('authorships').flatMap("institutions").map("display_name").uniq() + +path = source +value = get("primary_location.source.display_name") + +path = is_retracted +value = get("is_retracted").thru(bool=>bool === false ?"Non" : "Oui") + +path = pages +value = get("biblio.first_page").concat(self.biblio.last_page).uniq().join(" to ") + +path = volume +value = get("biblio.volume") + +path = issue +value = get("biblio.issue") + +; On récupère ici les 4 niveaux de classification, via Template strings on préfixe les valeurs de chaque niveau par un nombre correspondant à sa place hiérarchique. Nécéssaire pour créer un arbre hiérarchique. +path = classification +value = get("topics").map(item => `1 - ${item.domain.display_name}@2 - ${item.field.display_name}@3 - ${item.subfield.display_name}@4 - ${item.display_name}`).map(item=>item.split("@")) + +path = domains +value = get("topics").map("domain.display_name") + +path = fields +value = get("topics").map("field.display_name") + +path = subfields +value = get("topics").map("subfield.display_name") + +path = topics +value = get("topics").map("display_name") + +path = codes_iso2 +value = get("authorships").flatMap("countries").uniq() + +; On récupère ici un code langue, auquel on applique un constructeur Javascript, qui verbalise et traduit le code. "fr" devient "français". +path = language +value = get("language").thru(d => new Intl.DisplayNames(['FR'], { type: 'language'}).of(d)) + +[assign] +;On récupère des tableaux de codes Iso2, on enlève le code de la France. Puis on teste chaque tableau, s'il n'est pas vide on remplace par "Oui" (il y a d'autres pays donc collaboration), sinon par "Non". +path = collaborations_internationales +value = get("codes_iso2").pull("FR").thru(arr=>!_.isEmpty(arr)).thru(bool=>bool === false ?"Non" : "Oui") + +; On récupère ici un code Iso2, auquel on applique un constructeur Javascript, qui verbalise et traduit le code. "FRA" devient "France". +path = countries +value = get("codes_iso2").map(d => new Intl.DisplayNames(['FR'], { type: 'region'}).of(d)) + +; On récupère un tableau d'objets, puis on itère sur chaque objet avec un template string pour en faire une chaîne de caractères. +; Chaque chaîne est préfixée par "Goal", on récupère ensuite les derniers chiffres de la variable "id" qui correspond au numéro du goal. +; On ajoute enfin la variable "display_name" qui est le nom du goal. +path = sustainable_development_goals +value = get("sustainable_development_goals").map( item => `Goal ${item.id.match(/\/sdg\/(\d+)/)[1]} : ${item.display_name}`).thru(arr => _.isEmpty(arr) ? ["No sustainable development goal"] : arr) + +path = funder_display_name +value = get("grants").map("funder_display_name").uniq() + +; On récupère le nom des funders et leur id sous forme de matrice. On utilise ensuite _.compact dans chaque tableau pour que le _.join après ne concatène pas en cas d'absence de valeur à id, +; de sorte à ne pas avoir de valeur comme "funder : ". Le _.join sert à transformer la matrice en un seul tableau. On remplace ensuite les tableaux vides par ["n/a"]. +path = funders_award_id +value = get("grants").map(obj=>[obj.funder_display_name,obj.award_id]).map(subarr=>_.compact(subarr)).map(subarr => subarr.join(" : ")).thru(arr => _.isEmpty(arr) ? ["n/a"] : arr) + +; On teste si dans la liste des urls où sont déposés les "works", au moins une appartient à un portail Hal. +; La regex couvre l'ensemble des portails hal et la spécificité de leurs urls. (Oui et Non pour facette). +[assign] +path = is_hal +value = get("locations").map("landing_page_url").compact().some(item=>(/[^a-zA-Z0-9]hal[^a-zA-Z0-9]/).test(item)).thru(bool=>bool === false ?"Non" : "Oui") + +; On récupère le champ qui indique dans quelles bases bibliographiques sont indexés les "works". +; On concatène le champ "is_hal", on retire les "Non" et transforme les "Oui" en "hal". +[assign] +path = indexed_in_hal_enriched +value = get("indexed_in").concat(self.is_hal).pull("Non").map(item=>item === "Oui" ? "hal" : item).sort() + +; On récupère l'éditeur de la revue. ([] si vide, nécessaire pour les instructions et fonctions futures). +[assign] +path = publisher_only +value = get("primary_location.source.host_organization_name","n/a") + +; On récupère un tableau avec l'éditeur mais aussi, s'il en a, sa ou ses sociétés mères (3 niveaux de hiérarchie). +; L'ordre est aléatoire, on ne peut se baser sur l'index. (["n/a"] si vide, nécéssaire pour les instructions et fonctions futures). +[assign] +path = linked_publishers +value = get("primary_location.source.host_organization_lineage_names",["n/a"]) + +; On compare les 2 champ créés au-dessus, pour retirer du tableau les éditeurs du plus-bas niveau de hiérarchie. +; Si publisherOnly était au plus haut niveau (0) on obtient []. +; S'il était au niveau 1 on obtient son niveau supérieur (0). +; S'il était au plus bas on obtient ses 2 sociétés mères (0 & 1). +[assign] +path = xor_publisher +value = get("publisher_only").castArray().xor(self.linked_publishers) + +; On isole tous les tableaux vides. +[swing] +test = get("xor_publisher").isEmpty() + +; On leur réattribue leur unique valeur. +[swing/assign] +path = xor_publisher +value = get("publisher_only") + +; On associe le tout, ne reste plus que le cas des tableaux avec niveaux 0 et 1. +; On retire les parents directs des éditeurs de niveau 2, on n'a donc plus que les éditeurs du plus haut niveau. +[assign] +path = publisher +value = get("xor_publisher").pull("Springer Science+Business Media","Vandenhoeck & Ruprecht","University of California, Berkeley","University of California, Los Angeles","Siberian Branch of the Russian Academy of Sciences").toString().replace(null,"n/a").replace(undefined,"n/a") + +; On recupère l'abstract sous forme d'un tableau d'objets où keys sont les mots et values leur(s) position(s) dans l'abstract ([{"the":[0,12,25]}...]). +; Les keys contiennent donc des "." ou autres caractères spéciaux proscrits MongoDB Node.js driver. +; On transforme cela en un tableau où chaque value est "key:value", on découpe pour avoir une matrice. On parse les values en base 10 pour obtenir des entiers. +; On inverse ensuite les éléments dans chaque sous-tableau de la matrice. On effectue un tri dans la matrice, on ne garde plus que les mots et transforme le tout en un string. + +[assign] +path = identify_author_name_error +value = get("authorships").groupBy("author.display_name").map((authors, displayName) => ({author_display_name: displayName,count: _.size(authors),raw_author_names: _.filter(_.map(authors, author => author.raw_author_name),Boolean)})).filter(entry => entry.count > 1) + +[assign] +path = display_author_name_error +value = get("identify_author_name_error").map(item =>`${item.author_display_name} apparaît ${item.count} fois sous les noms : ${item.raw_author_names.join(" || ")}`) + +[assign] +path = check_single_affiliation +value = get("authorships").map("raw_affiliation_strings").map(item=>item.join()).uniq().compact().size().thru(v=>v===1? _.size(self.authorships):"").replace(/^1$/,"") + +[assign] +path = identify_same_affiliation_for_all_authors +value=get('authorships').map('raw_affiliation_strings').map(i=>_.size(i)).some(i=>i===0).thru(bool=>bool === true ? "" : self.check_single_affiliation) + +[assign] +path = retrieve_affiliation_normalization_errors +value= get('filtered_and_tested_raw_affiliation_strings').thru(bool=>bool===false ? self.filtered_raw_affiliation_strings : []) + +[assign] +path = abstract +value = get("abstract_inverted_index").flatMap((values, key) => values.map(value => [value, key])).sort((a, b) => a[0] - b[0]).map(item => item[1]).join(' ') + +;[F] +[exchange] +value = omit(["ids","fwci","concepts","grants","datasets","versions","is_authors_truncated","biblio","abstract_inverted_index","indexed_in","display_name","publisher_only","xor_publisher","publication_date","countries_distinct_count","institutions_distinct_count","corresponding_author_ids","corresponding_institution_ids","has_fulltext","fulltext_origin","cited_by_percentile_year","is_paratext","primary_topic","locations_count","referenced_works","related_works","ngrams_url","cited_by_api_url","created_date","updated_date","check_single_affiliation"]) +; }}} + diff --git a/openalex-dumps/Makefile b/openalex-dumps/Makefile new file mode 100644 index 0000000..886e0d7 --- /dev/null +++ b/openalex-dumps/Makefile @@ -0,0 +1,37 @@ +NPROCS = $(shell grep -c 'processor' /proc/cpuinfo) +MAKEFLAGS += --keep-going -j$(NPROCS) --max-load=$(NPROCS) +# This ensures the next time you run Make, it’ll properly re-run the failed +# rule, and guards against broken files. +# See https://tech.davis-hansson.com/p/make/#change-some-make-defaults +.DELETE_ON_ERROR: + +# To prevent deleting intermediate files (for controls) +.PRECIOUS: 02-download/%.jsonl 03-enrich/%.jsonl + +SOURCE_FILES := $(wildcard 01-query/*.txt) +TARGET_FILES := $(patsubst 01-query/%.txt, 04-report/%.log, $(SOURCE_FILES)) + +watch: ## Automatically build files when they change + while true; do \ + inotifywait -qr -e modify -e create -e delete -e move --exclude '/\.' 01-query; \ + $(MAKE) all; \ + done + +# Rules +all: $(TARGET_FILES) ## Build all files + echo $(TARGET_FILES) + +# cible : dépendance +04-report/%.log: 03-enrich/%.jsonl + mkdir -p $(@D) + wc -l $< > $@ + +03-enrich/%.jsonl: 02-download/%.jsonl + mkdir -p $(@D) + time npx ezs 03-enrich.ini < $< > $@.crdownload + mv $@.crdownload $@ + +02-download/%.jsonl: 01-query/%.txt + mkdir -p $(@D) + time npx ezs 02-download.ini < $< > $@.crdownload + mv $@.crdownload $@ diff --git a/openalex-dumps/README.md b/openalex-dumps/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openalex-dumps/README.md