Coverage for oc_meta / lib / cleaner.py: 96%

239 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-07-25 10:39 +0000

1# SPDX-FileCopyrightText: 2019 Silvio Peroni <silvio.peroni@unibo.it> 

2# SPDX-FileCopyrightText: 2019-2020 Fabio Mariani <fabio.mariani555@gmail.com> 

3# SPDX-FileCopyrightText: 2021 Simone Persiani <iosonopersia@gmail.com> 

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

5# 

6# SPDX-License-Identifier: ISC 

7 

8import html 

9import re 

10from collections import OrderedDict 

11from datetime import datetime 

12from typing import Tuple, Union 

13 

14from dateutil.parser import parse 

15from oc_ds_converter.oc_idmanager import ( 

16 DOIManager, 

17 ISBNManager, 

18 ISSNManager, 

19 ORCIDManager, 

20) 

21 

22from oc_meta.lib.master_of_regex import ( 

23 RE_COMMA_AND_SPACES, 

24 RE_INVALID_VI_PATTERNS, 

25 RE_ISSUES_VALID_PATTERNS, 

26 RE_VOLUMES_VALID_PATTERNS, 

27 split_name_and_ids, 

28) 

29 

30_HYPHEN_TRANS = str.maketrans( 

31 { 

32 "\u00ad": "\u002d", # Soft hyphen 

33 "\u06d4": "\u002d", # Arabic Full Stop 

34 "\u2010": "\u002d", # Hyphen 

35 "\u2011": "\u002d", # Non-breaking Hyphen 

36 "\u2012": "\u002d", # Figure Dash 

37 "\u2013": "\u002d", # En-Dash 

38 "\u2014": "\u002d", # Em-Dash 

39 "\u2043": "\u002d", # Hyphen Bullet 

40 "\u2212": "\u002d", # Minus Sign 

41 "\u2796": "\u002d", # Heavy Minus Sign 

42 "\u2cba": "\u002d", # Coptic Capital Letter Dialect-p Ni 

43 "\ufe58": "\u002d", # Small Em Dash 

44 } 

45) 

46 

47_SPACE_TRANS = str.maketrans( 

48 { 

49 "\u0009": "\u0020", # Character Tabulation 

50 "\u00a0": "\u0020", # No-break space 

51 "\u200b": "\u0020", # Zero width space 

52 "\u202f": "\u0020", # Narrow no-break space 

53 "\u2003": "\u0020", # Em Space 

54 "\u2005": "\u0020", # Four-Per-Em Space 

55 "\u2009": "\u0020", # Thin Space 

56 } 

57) 

58 

59# Translation table for control characters and extended ASCII to space 

60# Covers: 0x00-0x1F (control chars), 0x7F (DEL), 0x80-0xFF (extended ASCII) 

61_ASCII_CONTROL_TRANS = str.maketrans( 

62 {chr(i): " " for i in range(0x00, 0x20)} 

63 | {chr(0x7F): " "} 

64 | {chr(i): " " for i in range(0x80, 0x100)} 

65) 

66 

67_DOI_MANAGER = DOIManager(use_api_service=False, storage_manager=None) 

68_ISBN_MANAGER = ISBNManager() 

69_ISSN_MANAGER = ISSNManager() 

70_ORCID_MANAGER = ORCIDManager(use_api_service=False, storage_manager=None) 

71 

72 

73def normalize_hyphens(string: str) -> str: 

74 """ 

75 It replaces any hyphen, dash and minus sign with a hyphen-minus character. 

76 This is done for pages, IDs and dates. 

77 

78 .. list-table:: Comparison between the various characters similar to hyphen-minus 

79 :widths: 25 25 50 

80 :header-rows: 1 

81 

82 * - UTF-8 

83 - SIGN 

84 - NAME 

85 * - U+002D 

86 - - 

87 - Hyphen-minus 

88 * - U+00AD 

89 - ­ 

90 - Soft hyphen 

91 * - U+06D4 

92 - ۔ 

93 - Arabic Full Stop 

94 * - U+2010 

95 - ‐ 

96 - Hyphen 

97 * - U+2011 

98 - − 

99 - Non-breaking Hyphen 

100 * - U+2012 

101 - – 

102 - Figure Dash 

103 * - U+2013 

104 - – 

105 - En-Dash 

106 * - U+2014 

107 - — 

108 - Em-Dash 

109 * - U+2043 

110 - ⁃ 

111 - Hyphen Bullet 

112 * - U+2212 

113 - − 

114 - Minus Sign 

115 * - U+2796 

116 - ➖ 

117 - Heavy Minus Sign 

118 * - U+2CBA 

119 - Ⲻ 

120 - Coptic Capital Letter Dialect-p Ni 

121 * - U+FE58 

122 - ﹘ 

123 - Small Em Dash 

124 

125 :returns: str -- the string with normalized hyphens 

126 """ 

127 return string.translate(_HYPHEN_TRANS) 

128 

129 

130def normalize_spaces(string: str) -> str: 

131 """ 

132 It replaces any ambiguous spaces with a space. 

133 

134 .. list-table:: List of the various characters similar to the space 

135 :widths: 25 25 50 

136 :header-rows: 1 

137 

138 * - UTF-8 

139 - NAME 

140 * - U+0020 

141 - Space 

142 * - U+0009 

143 - Character Tabulation 

144 * - U+00A0 

145 - No-break space 

146 * - U+200B 

147 - Zero width space 

148 * - U+202F 

149 - Narrow no-break space 

150 * - U+2003 

151 - Em Space 

152 * - U+2005 

153 - Four-Per-Em Space 

154 * - U+2009 

155 - Thin Space 

156 

157 :returns: str -- the string with normalized spaces 

158 """ 

159 return string.translate(_SPACE_TRANS).replace("&nbsp;", "\u0020") 

160 

161 

162def clean_title(string: str, normalize: bool = True) -> str: 

163 """ 

164 Concerning titles of bibliographic resources ('venue' and 'title' columns), 

165 every word in the title is capitalized except for those that have capitals within them 

166 (probably acronyms, e.g. 'FaBiO and CiTO'). This exception, however, does not include entirely capitalized titles. 

167 Finally, null characters and spaces are removed. 

168 

169 :returns: str -- The cleaned title 

170 """ 

171 title = string 

172 if normalize: 

173 if title.isupper(): 

174 title = title.lower() 

175 words = title.split() 

176 for i, w in enumerate(words): 

177 if not any(x.isupper() for x in w): 

178 words[i] = w.title() 

179 return " ".join(words) 

180 return title 

181 

182 

183def _date_parse_hack(date: str) -> str: 

184 dt = parse(date, default=datetime(2001, 1, 1)) 

185 dt2 = parse(date, default=datetime(2002, 2, 2)) 

186 

187 if dt.year == dt2.year and dt.month == dt2.month and dt.day == dt2.day: 

188 clean_date = parse(date).strftime("%Y-%m-%d") 

189 elif dt.year == dt2.year and dt.month == dt2.month: 

190 clean_date = parse(date).strftime("%Y-%m") 

191 elif dt.year == dt2.year: 

192 clean_date = parse(date).strftime("%Y") 

193 else: 

194 clean_date = "" 

195 return clean_date 

196 

197 

198def clean_date(string: str) -> str: 

199 """ 

200 It tries to parse a date-string into a datetime object, 

201 considering both the validity of the format (YYYYY-MM-DD) and the value (e.g. 30 February is not a valid date). 

202 For example, a date 2020-02-30 will become 2020-02, because the day is invalid. 

203 On the other hand, 2020-27-12 will become 2020 since the day 

204 and month are invalid. 

205 If the year is not valid (e.g.year >9999) data would be totally discarded. 

206 

207 :returns: str -- The cleaned date or an empty string 

208 """ 

209 date = string 

210 try: 

211 date = _date_parse_hack(date) 

212 except ValueError: 

213 try: 

214 # e.g. 2021-12-17 

215 if len(date) == 10: 

216 try: 

217 # Maybe only the day is invalid, try year-month 

218 new_date = date[:-3] 

219 date = _date_parse_hack(new_date) 

220 except ValueError: 

221 try: 

222 # Maybe only the month is invalid, try year 

223 new_date = date[:-6] 

224 date = _date_parse_hack(new_date) 

225 except ValueError: 

226 date = "" 

227 # e.g. 2021-12 

228 elif len(date) == 7: 

229 # Maybe only the month is invalid, try year 

230 try: 

231 new_date = date[:-3] 

232 date = _date_parse_hack(new_date) 

233 except ValueError: 

234 date = "" 

235 else: 

236 date = "" 

237 except ValueError: 

238 date = "" 

239 return date 

240 

241 

242def clean_name(string: str) -> str: 

243 """ 

244 The first letter of each element of the name is capitalized and superfluous spaces are removed. 

245 

246 :returns: str -- The cleaned name 

247 """ 

248 name = string 

249 if "," in name: 

250 split_name = RE_COMMA_AND_SPACES.split(name) 

251 first_name = split_name[1].split() 

252 for i, w in enumerate(first_name): 

253 first_name[i] = clean_title(w) 

254 new_first_name = " ".join(first_name) 

255 surname = split_name[0].split() 

256 for i, w in enumerate(surname): 

257 surname[i] = clean_title(w) 

258 new_surname = " ".join(surname) 

259 if new_surname: 

260 return new_surname + ", " + new_first_name 

261 return "" 

262 split_name = name.split() 

263 for i, w in enumerate(split_name): 

264 split_name[i] = clean_title(w) 

265 return " ".join(split_name) 

266 

267 

268def clean_agent_name(string: str) -> str: 

269 """ 

270 Clean a responsible agent name (author, editor, publisher). 

271 

272 Removes unwanted characters while preserving letters, numbers, spaces, 

273 '&', apostrophes, and dots preceded by letters. Numbers and '&' are 

274 kept for organization names (e.g., "3M", "Smith & Sons"). 

275 Normalizes hyphens, decodes HTML entities, and removes extra spaces. 

276 

277 :returns: str -- The cleaned agent name. 

278 """ 

279 unwanted_characters = {"[", "]", ";", "?"} 

280 chars = [] 

281 for i, c in enumerate(string): 

282 if c == ".": 

283 if i > 0 and string[i - 1].isalpha(): 

284 chars.append(c) 

285 elif c not in unwanted_characters: 

286 chars.append(c) 

287 clean_string = " ".join("".join(chars).split()) 

288 clean_string = html.unescape(clean_string) 

289 clean_string = clean_string.translate(_HYPHEN_TRANS) 

290 return clean_string 

291 

292 

293def _normalize_ra_name(raw_name: str) -> str: 

294 """Normalize a RA name into one of: '', 'Full Name', 'Last, First', 'Last, '. 

295 

296 Returns '' when the name is absent, literally 'Not Available', or a 

297 comma-separated pair whose surname is missing. Bare names are run 

298 through :func:`clean_agent_name` to drop bracket / punctuation junk. 

299 """ 

300 name = raw_name.strip() 

301 if not name: 

302 return "" 

303 if "," in name: 

304 last, _, first = name.partition(",") 

305 last = last.strip() 

306 first = first.strip() 

307 if last.lower() == "not available": 

308 last = "" 

309 if first.lower() == "not available": 

310 first = "" 

311 if not last: 

312 return "" 

313 return f"{last}, {first}" if first else f"{last}, " 

314 cleaned = clean_agent_name(name) 

315 if cleaned.lower() == "not available": 

316 return "" 

317 return cleaned 

318 

319 

320def clean_ra_list(ra_list: list) -> list: 

321 """ 

322 Clean a list of responsible agents: normalize names, drop 'Not Available' 

323 entries, remove duplicates that share a name and at least one id, and 

324 strip identifiers that appear under more than one agent. 

325 

326 :returns: list -- The cleaned responsible agents' list 

327 """ 

328 

329 # Phase 1: parse each entry into (key, name, ids). The key groups entries 

330 # that belong to the same id bucket: named entries by their normalized 

331 # name, nameless (ids-only) entries by the raw input so each stays 

332 # distinct. 

333 parsed: list[tuple[str, str, list[str]]] = [] 

334 agents_ids: OrderedDict[str, OrderedDict[str, None]] = OrderedDict() 

335 for ra in ra_list: 

336 raw_name, ids_str = split_name_and_ids(ra) 

337 name = _normalize_ra_name(raw_name) 

338 ids = ids_str.split() 

339 if not name and not ids: 

340 continue 

341 key = name or ra 

342 parsed.append((key, name, ids)) 

343 if ids: 

344 agents_ids.setdefault(key, OrderedDict()).update(OrderedDict.fromkeys(ids)) 

345 

346 # Phase 2: identifiers bucketed under more than one key are shared and 

347 # must be dropped — they cannot unambiguously identify a single agent. 

348 id_occurrences: dict[str, int] = {} 

349 for bucket in agents_ids.values(): 

350 for identifier in bucket: 

351 id_occurrences[identifier] = id_occurrences.get(identifier, 0) + 1 

352 shared_ids = {i for i, count in id_occurrences.items() if count > 1} 

353 

354 # Phase 3: emit cleaned entries in input order, dropping later duplicates 

355 # that share at least one surviving id with a previous entry of the same 

356 # name. 

357 output: list[str] = [] 

358 seen_ids_by_name: OrderedDict[str, set[str]] = OrderedDict() 

359 for _, name, ids in parsed: 

360 kept_ids = [i for i in ids if i not in shared_ids] 

361 kept_ids_str = " ".join(kept_ids) 

362 if not name: 

363 output.append(f"[{kept_ids_str}]") 

364 continue 

365 kept_set = set(kept_ids) 

366 if name in seen_ids_by_name and seen_ids_by_name[name] & kept_set: 

367 continue 

368 seen_ids_by_name.setdefault(name, set()).update(kept_set) 

369 output.append(f"{name} [{kept_ids_str}]" if kept_ids else name) 

370 return output 

371 

372 

373def normalize_id(string: str) -> Union[str, None]: 

374 """ 

375 This function verifies and normalizes identifiers whose schema corresponds to a DOI, an ISSN, an ISBN or an ORCID. 

376 

377 :returns: Union[str, None] -- The normalized identifier if it is valid, None otherwise 

378 """ 

379 identifier = string.split(":", 1) 

380 schema = identifier[0].lower() 

381 value = identifier[1] 

382 if schema == "doi": 

383 valid_id = ( 

384 _DOI_MANAGER.normalise(value, include_prefix=True) 

385 if _DOI_MANAGER.syntax_ok(value) 

386 else None 

387 ) 

388 elif schema == "isbn": 

389 valid_id = ( 

390 _ISBN_MANAGER.normalise(value, include_prefix=True) 

391 if _ISBN_MANAGER.is_valid(value, get_extra_info=False) 

392 else None 

393 ) 

394 elif schema == "issn": 

395 if value == "0000-0000": 

396 valid_id = None 

397 else: 

398 try: 

399 valid_id = ( 

400 _ISSN_MANAGER.normalise(value, include_prefix=True) 

401 if _ISSN_MANAGER.is_valid(value, get_extra_info=False) 

402 else None 

403 ) 

404 except ValueError: 

405 print(value) 

406 raise (ValueError) 

407 elif schema == "orcid": 

408 valid_id = ( 

409 _ORCID_MANAGER.normalise(value, include_prefix=True) 

410 if _ORCID_MANAGER.is_valid(value, get_extra_info=False) 

411 else None 

412 ) 

413 else: 

414 valid_id = f"{schema}:{value}" 

415 return valid_id 

416 

417 

418def clean_volume_and_issue(row: dict) -> None: 

419 output = {"volume": "", "issue": "", "pub_date": ""} 

420 for field in {"volume", "issue"}: 

421 vi = row[field] 

422 vi = normalize_hyphens(vi) 

423 vi = normalize_spaces(vi).strip() 

424 vi = html.unescape(vi) 

425 for compiled_pattern, strategy in RE_INVALID_VI_PATTERNS.items(): 

426 capturing_groups = compiled_pattern.search(vi) 

427 if capturing_groups: 

428 if strategy == "del": 

429 row[field] = "" 

430 elif strategy == "do_nothing": 

431 row[field] = vi 

432 elif strategy == "s)": 

433 row[field] = f"{vi}s)" 

434 else: 

435 row[field] = "" 

436 whatever, volume, issue, pub_date = _fix_invalid_vi( 

437 capturing_groups, strategy 

438 ) 

439 row[field] = whatever if whatever else row[field] 

440 output["volume"] = volume if volume else "" 

441 output["issue"] = issue if issue else "" 

442 output["pub_date"] = pub_date if pub_date else "" 

443 row["volume"] = output["volume"] if not row["volume"] else row["volume"] 

444 row["issue"] = output["issue"] if not row["issue"] else row["issue"] 

445 row["pub_date"] = output["pub_date"] if not row["pub_date"] else row["pub_date"] 

446 switch_vi = {"volume": "", "issue": ""} 

447 for field in {"volume", "issue"}: 

448 vi = row[field] 

449 for compiled_pattern in RE_VOLUMES_VALID_PATTERNS: 

450 if compiled_pattern.search(vi): 

451 if field == "issue": 

452 switch_vi["volume"] = vi 

453 for compiled_pattern in RE_ISSUES_VALID_PATTERNS: 

454 if compiled_pattern.search(vi): 

455 if field == "volume": 

456 switch_vi["issue"] = vi 

457 if switch_vi["volume"] and switch_vi["issue"]: 

458 row["volume"] = switch_vi["volume"] 

459 row["issue"] = switch_vi["issue"] 

460 elif switch_vi["volume"] and not row["volume"]: 

461 row["volume"] = switch_vi["volume"] 

462 row["issue"] = "" 

463 row["type"] = ( 

464 "journal volume" if row["type"] == "journal issue" else row["type"] 

465 ) 

466 elif switch_vi["issue"] and not row["issue"]: 

467 row["issue"] = switch_vi["issue"] 

468 row["volume"] = "" 

469 row["type"] = ( 

470 "journal issue" if row["type"] == "journal volume" else row["type"] 

471 ) 

472 

473 

474def _fix_invalid_vi( 

475 capturing_groups: re.Match, strategy: str 

476) -> Tuple[str | None, str | None, str | None, str | None]: 

477 vol_group = 1 if "vol_iss" in strategy else 2 

478 iss_group = 1 if "iss_vol" in strategy else 2 

479 whatever = None 

480 volume = None 

481 issue = None 

482 pub_date = None 

483 if "vol" in strategy and "iss" in strategy: 

484 volume = capturing_groups.group(vol_group) 

485 issue = capturing_groups.group(iss_group) 

486 if "year" in strategy: 

487 pub_date = capturing_groups.group(3) 

488 elif strategy == "all": 

489 whatever = capturing_groups.group(1) 

490 elif strategy == "sep": 

491 first = capturing_groups.group(1) 

492 second = capturing_groups.group(2) 

493 whatever = f"{first}-{second}" 

494 return whatever, volume, issue, pub_date 

495 

496 

497def remove_ascii(string: str) -> str: 

498 clean_string = string.translate(_ASCII_CONTROL_TRANS) 

499 return " ".join(clean_string.split())