Coverage for oc_meta / run / orcid_process.py: 87%
86 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 10:39 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 10:39 +0000
1# SPDX-FileCopyrightText: 2019-2020 Fabio Mariani <fabio.mariani555@gmail.com>
2# SPDX-FileCopyrightText: 2021-2026 Arcangelo Massari <arcangelo.massari@unibo.it>
3#
4# SPDX-License-Identifier: ISC
6import os
7import re
8from argparse import ArgumentParser
10from bs4 import BeautifulSoup
11from oc_ds_converter.oc_idmanager import DOIManager
12from rich.console import Console
14from oc_meta.lib.console import create_progress
15from oc_meta.lib.csvmanager import CSVManager
16from oc_meta.lib.master_of_regex import orcid_pattern
19class IndexOrcidDoi:
20 def __init__(self, output_path: str, threshold: int = 10000):
21 self.file_counter = 0
22 self.threshold = threshold
23 self.console = Console()
24 self.console.print("[cyan][INFO][/cyan] Loading existing CSV files")
25 self.orcid_re = re.compile(orcid_pattern)
26 self.doimanager = DOIManager(use_api_service=False)
27 self.csvstorage = CSVManager(output_path=output_path)
28 self.cache = self._build_cache()
30 def _build_cache(self) -> set[str]:
31 cache = set()
32 for values in self.csvstorage.data.values():
33 for value in values:
34 orcid = self._extract_orcid(value)
35 if orcid:
36 cache.add(orcid)
37 return cache
39 def _extract_orcid(self, text: str) -> str | None:
40 match = self.orcid_re.search(text)
41 return match.group(0) if match else None
43 def explorer(self, summaries_path: str) -> None:
44 self.console.print("[cyan][INFO][/cyan] Counting files to process")
45 files_to_process = [
46 os.path.join(fold, filename)
47 for fold, _, files in os.walk(summaries_path)
48 for filename in files
49 if filename.endswith(".xml")
50 and self._extract_orcid(filename) not in self.cache
51 ]
52 processed_files = len(self.cache)
53 del self.cache
54 progress = create_progress()
55 with progress:
56 task = progress.add_task("Processing files", total=len(files_to_process))
57 for file in files_to_process:
58 self._process_file(file)
59 self.file_counter += 1
60 if self.file_counter % self.threshold == 0:
61 start = processed_files + self.file_counter - self.threshold + 1
62 end = processed_files + self.file_counter
63 self.csvstorage.dump_data(f"{start}-{end}.csv")
64 progress.advance(task)
65 if self.csvstorage.data_to_store:
66 start = (
67 processed_files
68 + self.file_counter
69 - (self.file_counter % self.threshold)
70 + 1
71 )
72 end = processed_files + self.file_counter
73 self.csvstorage.dump_data(f"{start}-{end}.csv")
75 def _process_file(self, file_path: str) -> None:
76 orcid = self._extract_orcid(file_path)
77 if not orcid:
78 return
79 with open(file_path, "r", encoding="utf-8") as xml_file:
80 xml_soup = BeautifulSoup(xml_file, "xml")
81 name = self._extract_name(xml_soup)
82 author = f"{name} [{orcid}]" if name else f"[{orcid}]"
83 valid_doi = False
84 for el in xml_soup.find_all("common:external-id"):
85 id_type = el.find("common:external-id-type")
86 rel = el.find("common:external-id-relationship")
87 if not (id_type and rel):
88 continue
89 if id_type.get_text().lower() != "doi" or rel.get_text().lower() != "self":
90 continue
91 doi_el = el.find("common:external-id-value")
92 if not doi_el:
93 continue
94 doi = self.doimanager.normalise(doi_el.get_text())
95 if not doi:
96 continue
97 valid_doi = True
98 self.csvstorage.add_value(doi, author)
99 if not valid_doi:
100 self.csvstorage.add_value("None", f"[{orcid}]")
102 def _extract_name(self, xml_soup: BeautifulSoup) -> str | None:
103 family_name_el = xml_soup.find("personal-details:family-name")
104 given_name_el = xml_soup.find("personal-details:given-names")
105 if family_name_el and given_name_el:
106 return f"{family_name_el.get_text()}, {given_name_el.get_text()}"
107 if family_name_el:
108 return family_name_el.get_text()
109 if given_name_el:
110 return given_name_el.get_text()
111 return None
114if __name__ == "__main__": # pragma: no cover
115 arg_parser = ArgumentParser(
116 "orcid_process.py",
117 description="Build a CSV index of DOIs associated with ORCIDs from XML summary files",
118 )
119 arg_parser.add_argument(
120 "-out",
121 "--output",
122 dest="output_path",
123 required=True,
124 help="Output directory for CSV files",
125 )
126 arg_parser.add_argument(
127 "-s",
128 "--summaries",
129 dest="summaries_path",
130 required=True,
131 help="Directory containing ORCID XML summaries (scanned recursively)",
132 )
133 arg_parser.add_argument(
134 "-t",
135 "--threshold",
136 dest="threshold",
137 type=int,
138 default=10000,
139 help="Number of files to process before saving a CSV chunk (default: 10000)",
140 )
141 args = arg_parser.parse_args()
142 iod = IndexOrcidDoi(output_path=args.output_path, threshold=args.threshold)
143 iod.explorer(summaries_path=args.summaries_path)