Coverage for src/oc_graphenricher/APIs/__init__.py: 92%

339 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 11:55 +0000

1# SPDX-FileCopyrightText: 2021 Gabriele Pisciotta <ga.pisciotta@gmail.com> 

2# SPDX-FileCopyrightText: 2021 Simone Persiani 

3# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

4# 

5# SPDX-License-Identifier: ISC 

6 

7from __future__ import annotations 

8 

9import json 

10import logging 

11import unicodedata 

12from abc import ABC, abstractmethod 

13from http import HTTPStatus 

14from pathlib import Path 

15from time import sleep 

16from typing import cast 

17from urllib.parse import quote 

18 

19import Levenshtein 

20import requests 

21import requests_cache 

22from oc_ocdm.graph.graph_entity import GraphEntity 

23from requests.exceptions import RequestException 

24 

25LOGGER = logging.getLogger(__name__) 

26 

27USER_AGENT = "GraphEnricher (via OpenCitations - http://opencitations.net; mailto:contact@opencitations.net)" 

28CROSSREF_ROWS = 4 

29TITLE_KEYWORD_LIMIT = 4 

30CROSSREF_TITLE_THRESHOLD = 0.8 

31YEAR_LENGTH = 4 

32DEFAULT_RETRY_ATTEMPTS = 3 

33DEFAULT_BACKOFF_FACTOR = 1.0 

34RETRY_STATUS_CODES = { 

35 HTTPStatus.TOO_MANY_REQUESTS, 

36 HTTPStatus.INTERNAL_SERVER_ERROR, 

37 HTTPStatus.BAD_GATEWAY, 

38 HTTPStatus.SERVICE_UNAVAILABLE, 

39 HTTPStatus.GATEWAY_TIMEOUT, 

40} 

41 

42JsonDict = dict[str, object] 

43AuthorTuple = tuple[str | None, str | None, str | None, object] 

44IdentifierTuple = tuple[str | None, str | None] 

45 

46 

47def _default_headers(content_type: str | None = None) -> dict[str, str]: 

48 headers = {"User-Agent": USER_AGENT} 

49 if content_type is not None: 

50 headers["Content-Type"] = content_type 

51 return headers 

52 

53 

54def _normalize_ascii(value: str) -> str: 

55 return unicodedata.normalize("NFKD", value).encode("ASCII", "ignore").decode("utf-8") 

56 

57 

58def _get_with_retries( 

59 url: str, 

60 headers: dict[str, str], 

61 timeout: float, 

62 label: str, 

63 params: dict[str, str] | None = None, 

64 max_attempts: int = DEFAULT_RETRY_ATTEMPTS, 

65 backoff_factor: float = DEFAULT_BACKOFF_FACTOR, 

66) -> requests.Response | None: 

67 last_error: object = "no attempts" 

68 for attempt in range(max_attempts): 

69 try: 

70 if params is None: 

71 response = requests.get(url, headers=headers, timeout=timeout) 

72 else: 

73 response = requests.get(url, headers=headers, timeout=timeout, params=params) 

74 except RequestException as error: 

75 last_error = error 

76 else: 

77 if response.status_code == HTTPStatus.OK: 

78 return response 

79 if response.status_code not in RETRY_STATUS_CODES: 

80 return None 

81 last_error = HTTPStatus(response.status_code) 

82 

83 if attempt + 1 < max_attempts: 

84 sleep(backoff_factor * 2**attempt) 

85 

86 LOGGER.warning("%s retry attempts exhausted for %s: %r", label, url, last_error) 

87 return None 

88 

89 

90class QueryInterface(ABC): 

91 def __init__(self) -> None: 

92 """ 

93 Initialize the query interface. 

94 

95 The interface installs the shared requests cache used by concrete query backends. 

96 """ 

97 requests_cache.install_cache("GraphEnricher_cache") 

98 

99 @abstractmethod 

100 def query(self, *args: object, **kwargs: object) -> object: 

101 raise NotImplementedError 

102 

103 

104class VIAF(QueryInterface): 

105 def __init__(self) -> None: 

106 """ 

107 Initialize the VIAF query backend. 

108 

109 The backend extracts VIAF identifiers for authors by querying viaf.org. 

110 """ 

111 super().__init__() 

112 self.headers = { 

113 "User-Agent": USER_AGENT, 

114 "Accept": "application/json", 

115 } 

116 self.api_url = ( 

117 'http://www.viaf.org/viaf/search?local.title+all+"{}"&query=local.names+all+"{}"' 

118 "&sortKeys=holdingscount&recordSchema=BriefVIAF" 

119 ) 

120 

121 def query(self, given_name: str, family_name: str, title: str) -> str | None: 

122 """ 

123 Having specified the author's names and the title of a paper, extract a VIAF. 

124 

125 :param given_name: author's given name 

126 :param family_name: author's family name 

127 :param title: paper's title 

128 :return: VIAF, if exists, otherwise None 

129 """ 

130 name = f"{given_name} {family_name}".strip() 

131 query = self.api_url.format(quote(title), quote(name)) 

132 response = _get_with_retries(query, self.headers, 60, "[GraphEnricher-VIAF]") 

133 if response is None: 

134 return None 

135 

136 data = response.json() 

137 response_data = data["searchRetrieveResponse"] 

138 number_of_records = response_data["numberOfRecords"]["content"] 

139 if int(number_of_records) != 1: 

140 return None 

141 

142 record_data = response_data["records"]["record"]["recordData"] 

143 return str(record_data["v:VIAFCluster"]["v:viafID"]["content"]) 

144 

145 

146class Wikidata(QueryInterface): 

147 def __init__(self) -> None: 

148 """ 

149 Initialize the Wikidata query backend. 

150 

151 The backend queries Wikidata by means of another identifier and checks whether a related entity exists. 

152 """ 

153 super().__init__() 

154 self.headers = { 

155 "User-Agent": USER_AGENT, 

156 "Accept": "application/json", 

157 } 

158 self.api_url = "https://query.wikidata.org/sparql" 

159 self.base_query = """ 

160 SELECT ?item WHERE {{ 

161 ?item p:{property} ?x. 

162 ?x ps:{property} "{literal}". 

163 }} LIMIT 1 

164 """ 

165 self.schema_properties = { 

166 "doi": "P356", 

167 "issn": "P236", 

168 "orcid": "P496", 

169 "viaf": "P214", 

170 "pmid": "P698", 

171 "pmcid": "P932", 

172 } 

173 

174 def query(self, entity: str, schema: str) -> str | None: 

175 """ 

176 Query Wikidata, given the literal of an identifier and its schema. 

177 

178 :param entity: the literal of the given identifier 

179 :param schema: the schema of the given identifier 

180 :return: Wikidata ID if found, otherwise None 

181 """ 

182 if schema not in self.schema_properties: 

183 return None 

184 

185 literal = entity.upper() if schema == "doi" else entity 

186 query = self.base_query.format(property=self.schema_properties[schema], literal=literal) 

187 response = _get_with_retries( 

188 self.api_url, 

189 self.headers, 

190 60, 

191 "[GraphEnricher-Wikidata]", 

192 params={"format": "json", "query": query}, 

193 ) 

194 if response is None: 

195 return None 

196 

197 try: 

198 data = response.json() 

199 return data["results"]["bindings"][0]["item"]["value"].split("/")[-1] 

200 except IndexError: 

201 return None 

202 

203 

204class Crossref(QueryInterface): 

205 def __init__( 

206 self, 

207 crossref_min_similarity_score: float = 0.95, 

208 max_iteration: int = DEFAULT_RETRY_ATTEMPTS, 

209 sec_to_wait: float = DEFAULT_BACKOFF_FACTOR, 

210 headers: dict[str, str] | None = None, 

211 timeout: int = 30, 

212 *, 

213 is_json: bool = True, 

214 ) -> None: 

215 """ 

216 Initialize the Crossref query backend. 

217 

218 The backend extracts DOIs, ISSNs, and publisher IDs. 

219 """ 

220 super().__init__() 

221 

222 self.max_iteration = max_iteration 

223 self.sec_to_wait = sec_to_wait 

224 self.headers = headers if headers is not None else _default_headers() 

225 self.timeout = timeout 

226 self.is_json = is_json 

227 self.crossref_min_similarity_score = crossref_min_similarity_score 

228 self.__crossref_doi_url = "https://api.crossref.org/works/" 

229 self.__crossref_journal_url = "https://api.crossref.org/journals/" 

230 stopwords_path = Path(__file__).with_name("stopwords-it.txt") 

231 with stopwords_path.open(encoding="utf-8") as stopwords_file: 

232 self.stoplist = {line.strip() for line in stopwords_file} 

233 

234 def _cleaning_title(self, title: str) -> str: 

235 """ 

236 Clean a given title, filtering the words according to a stoplist. 

237 

238 :param title: the title string 

239 :return: the cleaned title 

240 """ 

241 keywords = [word for word in title.split(" ") if word not in self.stoplist] 

242 return " ".join(keywords[:TITLE_KEYWORD_LIMIT]) 

243 

244 def query_journal(self, issn: str) -> list[str] | None: 

245 """ 

246 Query Crossref to get a list of other ISSNs for an ISSN. 

247 

248 :param issn: the ISSN of the bibliographic entity 

249 :return: a list that contains any other ISSN found, otherwise an empty list 

250 """ 

251 query = self.__crossref_journal_url + issn 

252 response = _get_with_retries( 

253 query, 

254 self.headers, 

255 self.timeout, 

256 "[GraphEnricher-Crossref]", 

257 max_attempts=self.max_iteration, 

258 backoff_factor=self.sec_to_wait, 

259 ) 

260 if response is None: 

261 return None 

262 

263 data = response.json() 

264 new_issn = cast("list[str]", data["message"]["ISSN"]) 

265 if issn in new_issn: 

266 new_issn.remove(issn) 

267 return new_issn 

268 

269 def query_publisher(self, doi: str) -> str | None: 

270 """ 

271 Extract the identifier of a publisher starting from a given DOI. 

272 

273 :param doi: the DOI of the paper 

274 :return: a string representing the ID of the publisher, otherwise None 

275 """ 

276 url_cr = self.__crossref_doi_url + doi 

277 response = _get_with_retries( 

278 url_cr, 

279 self.headers, 

280 self.timeout, 

281 "[GraphEnricher-Crossref-publisher]", 

282 max_attempts=self.max_iteration, 

283 backoff_factor=self.sec_to_wait, 

284 ) 

285 if response is None: 

286 return None 

287 

288 data = response.json() 

289 return str(data["message"]["member"]) 

290 

291 def query(self, fullnames: list[tuple[str | None, str | None]], title: str, year: str | None) -> str | None: 

292 """ 

293 Extract the DOI, given authors, title and year of publication. 

294 

295 :param fullnames: a list composed of a tuple of <name, family_name> (e.g.: [ ("Gabriele", "Pisciotta") ] 

296 :param title: the title of the paper 

297 :param year: a string that represent the year of publication 

298 :return: the DOI found, otherwise None 

299 """ 

300 author_query, exist_author, name, surname = self.__author_query(fullnames) 

301 query = f"query.bibliographic={self._cleaning_title(title)}{author_query}" 

302 query += f"&rows={CROSSREF_ROWS}&select=DOI,title,author,issued" 

303 url_cr = f"https://api.crossref.org/works?{query}" 

304 response = _get_with_retries( 

305 url_cr, 

306 self.headers, 

307 self.timeout, 

308 "[GraphEnricher-Crossref]", 

309 max_attempts=self.max_iteration, 

310 backoff_factor=self.sec_to_wait, 

311 ) 

312 if response is None: 

313 return None 

314 

315 data = response.json() 

316 return self.__best_doi(data, title, year, exist_author=exist_author, name=name, surname=surname) 

317 

318 def __author_query(self, fullnames: list[tuple[str | None, str | None]]) -> tuple[str, bool, str, str]: 

319 query = "" 

320 exist_author = False 

321 name = "" 

322 surname = "" 

323 for fullname in fullnames: 

324 name, surname = self.__normalized_fullname(fullname) 

325 if name or surname: 

326 exist_author = True 

327 query += f"&query.author={name} {surname}".rstrip() 

328 return query, exist_author, name, surname 

329 

330 def __normalized_fullname(self, fullname: tuple[str | None, str | None]) -> tuple[str, str]: 

331 name = fullname[0].lower() if fullname[0] is not None else "" 

332 surname = fullname[1].lower() if fullname[1] is not None else "" 

333 return name, surname 

334 

335 def __best_doi( 

336 self, 

337 data: JsonDict, 

338 title: str, 

339 year: str | None, 

340 *, 

341 exist_author: bool, 

342 name: str, 

343 surname: str, 

344 ) -> str | None: 

345 doi = None 

346 message = cast("JsonDict", data["message"]) 

347 items = cast("list[JsonDict]", message["items"]) 

348 if items: 

349 possible = [ 

350 ( 

351 *self.__score_crossref_item( 

352 item, 

353 title, 

354 year, 

355 exist_author=exist_author, 

356 name=name, 

357 surname=surname, 

358 ), 

359 item, 

360 ) 

361 for item in items 

362 ] 

363 best_title, best_authors, _best_year, best_item = sorted(possible, key=lambda item: item[:3])[-1] 

364 if best_title > CROSSREF_TITLE_THRESHOLD and (not exist_author or best_authors >= 1) and "DOI" in best_item: 

365 doi = str(best_item["DOI"]) 

366 return doi 

367 

368 def __score_crossref_item( 

369 self, 

370 item: JsonDict, 

371 title: str, 

372 year: str | None, 

373 *, 

374 exist_author: bool, 

375 name: str, 

376 surname: str, 

377 ) -> tuple[float, int, int]: 

378 point_year = self.__year_score(item, year) 

379 point_authors = self.__author_score(item, exist_author=exist_author, name=name, surname=surname) 

380 point_title = self.__title_score(item, title) 

381 return point_title, point_authors, point_year 

382 

383 def __year_score(self, item: JsonDict, year: str | None) -> int: 

384 year_int = self.__year_int(year) 

385 if year_int is None or "issued" not in item: 

386 return 0 

387 issued = cast("JsonDict", item["issued"]) 

388 if "date-parts" not in issued: 

389 return 0 

390 date_parts = cast("list[list[int | None]]", issued["date-parts"]) 

391 if not date_parts or not date_parts[0] or date_parts[0][0] is None: 

392 return 0 

393 return 3 if int(date_parts[0][0]) == year_int else 0 

394 

395 def __year_int(self, year: str | None) -> int | None: 

396 if year is None: 

397 return None 

398 year_string = str(year) 

399 if "-" in year_string: 

400 for element_of_year in year_string.split("-"): 

401 if len(element_of_year) == YEAR_LENGTH: 

402 year_string = element_of_year 

403 break 

404 return int(year_string) 

405 

406 def __author_score(self, item: JsonDict, *, exist_author: bool, name: str, surname: str) -> int: 

407 if not exist_author or "author" not in item: 

408 return 0 

409 point_authors = 0 

410 authors = cast("list[JsonDict]", item["author"]) 

411 for author in authors: 

412 if "family" not in author: 

413 continue 

414 family = cast("str", author["family"]).lower() 

415 if "given" not in author: 

416 if family == surname: 

417 point_authors += 1 

418 continue 

419 given = cast("str", author["given"]).lower() 

420 if family == surname and given == name: 

421 point_authors += 2 

422 elif family == surname and name and given and given[0] == name[0]: 

423 point_authors += 1 

424 return point_authors 

425 

426 def __title_score(self, item: JsonDict, title: str) -> float: 

427 if "title" not in item: 

428 return 0 

429 title_values = cast("list[str]", item["title"]) 

430 if not title_values: 

431 return 0 

432 return Levenshtein.ratio(title, title_values[0].lower()) 

433 

434 

435class ORCID(QueryInterface): 

436 def __init__( 

437 self, 

438 max_iteration: int = DEFAULT_RETRY_ATTEMPTS, 

439 sec_to_wait: float = DEFAULT_BACKOFF_FACTOR, 

440 headers: dict[str, str] | None = None, 

441 timeout: int = 30, 

442 repok: object = None, 

443 reperr: object = None, 

444 *, 

445 is_json: bool = True, 

446 ) -> None: 

447 """ 

448 Initialize the ORCID query backend. 

449 

450 The backend extracts ORCID identifiers. 

451 """ 

452 del repok, reperr 

453 super().__init__() 

454 

455 self.max_iteration = max_iteration 

456 self.sec_to_wait = sec_to_wait 

457 self.headers = headers if headers is not None else _default_headers("application/json") 

458 self.timeout = timeout 

459 self.is_json = is_json 

460 self.__orcid_api_url = "https://pub.orcid.org/v2.1/search?q=" 

461 self.__personal_url = "https://pub.orcid.org/v2.1/%s/personal-details" 

462 

463 def query(self, authors: list[AuthorTuple], identifiers: list[IdentifierTuple]) -> list[AuthorTuple] | None: 

464 """ 

465 Given a list of authors and a list of identifiers, returns the ORCIDs in the list of authors. 

466 

467 :param authors: a list of tuples in the following form [ (name, family_name, ORCID, ar_object) ] 

468 :param identifiers: a list of identifiers of the bibliographic resource 

469 :return: the authors list enriched with the ORCID identifier 

470 """ 

471 if len(identifiers) == 0: 

472 return None 

473 

474 records = self._get_orcid_records(identifiers, authors) 

475 to_return: dict[tuple[str | None, str | None], str] = {} 

476 if records is not None: 

477 records_data = cast("JsonDict", records) 

478 results = cast("list[JsonDict]", records_data["result"]) 

479 for record in results: 

480 orcid_identifier = cast("JsonDict", record["orcid-identifier"]) 

481 orcid_id = str(orcid_identifier["path"]) 

482 self.__collect_matching_orcid(authors, to_return, orcid_id) 

483 

484 return [(author[0], author[1], to_return.get((author[0], author[1])), author[3]) for author in authors] 

485 

486 def _get_orcid_records( 

487 self, 

488 identifiers: list[IdentifierTuple], 

489 family_names: list[AuthorTuple], 

490 ) -> JsonDict | str | None: 

491 identifier_query = self.__identifier_query(identifiers) 

492 family_query = self.__family_query(family_names) 

493 if identifier_query and family_query: 

494 cur_query = f"{identifier_query} AND ({family_query})" 

495 elif identifier_query: 

496 cur_query = identifier_query 

497 else: 

498 cur_query = family_query 

499 

500 if cur_query == "": 

501 return None 

502 

503 query_url = self.__orcid_api_url + quote(cur_query) 

504 return self.__get_data(query_url) 

505 

506 def __collect_matching_orcid( 

507 self, 

508 authors: list[AuthorTuple], 

509 to_return: dict[tuple[str | None, str | None], str], 

510 orcid_id: str, 

511 ) -> None: 

512 personal_details = self.__get_data(self.__personal_url % orcid_id.upper()) 

513 if personal_details is None: 

514 return 

515 

516 personal_data = cast("JsonDict", personal_details) 

517 name = cast("JsonDict", personal_data["name"]) 

518 given_names = cast("JsonDict", name["given-names"]) 

519 family_name_data = cast("JsonDict", name["family-name"]) 

520 given_name = str(given_names["value"]).lower() 

521 family_name = str(family_name_data["value"]).lower() 

522 

523 for author in authors: 

524 if self.__matches_author(author, given_name, family_name) and to_return.get((author[0], author[1])) is None: 

525 to_return[(author[0], author[1])] = orcid_id.upper() 

526 

527 def __matches_author(self, author: AuthorTuple, given_name: str, family_name: str) -> bool: 

528 if author[2] is not None or author[1] is None: 

529 return False 

530 if author[1].lower() not in family_name: 

531 return False 

532 return author[0] is None or author[0].lower() in given_name 

533 

534 def __identifier_query(self, identifiers: list[IdentifierTuple]) -> str: 

535 terms = [] 

536 for scheme, value in identifiers: 

537 if value is None: 

538 continue 

539 if scheme == GraphEntity.iri_doi: 

540 terms.extend(self.__doi_terms(value)) 

541 elif scheme == GraphEntity.iri_isbn: 

542 terms.append(f'isbn:"{value}"') 

543 elif scheme == GraphEntity.iri_pmid: 

544 terms.append(f'pmid-self:"{value}"') 

545 if not terms: 

546 return "" 

547 return f"({' OR '.join(terms)})" 

548 

549 def __doi_terms(self, doi_string: str) -> list[str]: 

550 terms = [f'doi-self:"{doi_string}"'] 

551 doi_string_l = doi_string.lower() 

552 doi_string_u = doi_string.upper() 

553 if doi_string_l != doi_string: 

554 terms.append(f'doi-self:"{doi_string_l}"') 

555 if doi_string_u != doi_string: 

556 terms.append(f'doi-self:"{doi_string_u}"') 

557 return terms 

558 

559 def __family_query(self, family_names: list[AuthorTuple]) -> str: 

560 terms = [] 

561 for given_names, family_name, _orcid, _entity in family_names: 

562 if family_name is None: 

563 continue 

564 term = f'family-name:"{_normalize_ascii(family_name)}"' 

565 if given_names: 

566 term += f' AND given-names:"{_normalize_ascii(given_names)}"' 

567 terms.append(term) 

568 return " OR ".join(terms) 

569 

570 def __get_data(self, get_url: str) -> JsonDict | str | None: 

571 """ 

572 Send requests. 

573 

574 :param get_url: the URL to query 

575 :return: results if found, otherwise None 

576 """ 

577 response = _get_with_retries( 

578 get_url, 

579 self.headers, 

580 self.timeout, 

581 "[GraphEnricher-ORCID]", 

582 max_attempts=self.max_iteration, 

583 backoff_factor=self.sec_to_wait, 

584 ) 

585 if response is None: 

586 return None 

587 if self.is_json: 

588 return cast("JsonDict", json.loads(response.text)) 

589 return response.text 

590 

591 

592class OpenAlex(QueryInterface): 

593 def __init__(self) -> None: 

594 super().__init__() 

595 self.headers = {"User-Agent": USER_AGENT} 

596 self.api_url_works = "https://api.openalex.org/works" 

597 self.api_url_sources = "https://api.openalex.org/sources" 

598 

599 def query(self, entity: str, schema: str) -> list[str] | None: 

600 schema = schema.lower() 

601 query = self.__query_url(entity, schema) 

602 result = None 

603 if query is None: 

604 LOGGER.warning("[GraphEnricher-OpenAlex]:The specified schema '%s' is not supported", schema) 

605 else: 

606 response = _get_with_retries(query, self.headers, 60, "[GraphEnricher-OpenAlex]") 

607 if response is not None: 

608 result = self.__response_results(response) 

609 return result 

610 

611 def __response_results(self, response: requests.Response) -> list[str] | None: 

612 data = response.json() 

613 results = cast("list[JsonDict]", data["results"]) 

614 if not results: 

615 return None 

616 return [str(result["id"]).replace("https://openalex.org/", "") for result in results] 

617 

618 def __query_url(self, entity: str, schema: str) -> str | None: 

619 if schema in {"doi", "pmid", "pmcid"}: 

620 return f"{self.api_url_works}?filter={schema}:{entity}&select=id" 

621 if schema == "issn": 

622 return f"{self.api_url_sources}?filter={schema}:{entity}&select=id" 

623 return None