validate_citations.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. #!/usr/bin/env python3
  2. """
  3. Citation Validation Tool
  4. Validate BibTeX files for accuracy, completeness, and format compliance.
  5. """
  6. import sys
  7. import re
  8. import requests
  9. import argparse
  10. import json
  11. from typing import Dict, List, Tuple, Optional
  12. from collections import defaultdict
  13. class CitationValidator:
  14. """Validate BibTeX entries for errors and inconsistencies."""
  15. def __init__(self):
  16. self.session = requests.Session()
  17. self.session.headers.update({
  18. 'User-Agent': 'CitationValidator/1.0 (Citation Management Tool)'
  19. })
  20. # Required fields by entry type
  21. self.required_fields = {
  22. 'article': ['author', 'title', 'journal', 'year'],
  23. 'book': ['title', 'publisher', 'year'], # author OR editor
  24. 'inproceedings': ['author', 'title', 'booktitle', 'year'],
  25. 'incollection': ['author', 'title', 'booktitle', 'publisher', 'year'],
  26. 'phdthesis': ['author', 'title', 'school', 'year'],
  27. 'mastersthesis': ['author', 'title', 'school', 'year'],
  28. 'techreport': ['author', 'title', 'institution', 'year'],
  29. 'misc': ['title', 'year']
  30. }
  31. # Recommended fields
  32. self.recommended_fields = {
  33. 'article': ['volume', 'pages', 'doi'],
  34. 'book': ['isbn'],
  35. 'inproceedings': ['pages'],
  36. }
  37. def parse_bibtex_file(self, filepath: str) -> List[Dict]:
  38. """
  39. Parse BibTeX file and extract entries.
  40. Args:
  41. filepath: Path to BibTeX file
  42. Returns:
  43. List of entry dictionaries
  44. """
  45. try:
  46. with open(filepath, 'r', encoding='utf-8') as f:
  47. content = f.read()
  48. except Exception as e:
  49. print(f'Error reading file: {e}', file=sys.stderr)
  50. return []
  51. entries = []
  52. # Match BibTeX entries
  53. pattern = r'@(\w+)\s*\{\s*([^,\s]+)\s*,(.*?)\n\}'
  54. matches = re.finditer(pattern, content, re.DOTALL | re.IGNORECASE)
  55. for match in matches:
  56. entry_type = match.group(1).lower()
  57. citation_key = match.group(2).strip()
  58. fields_text = match.group(3)
  59. # Parse fields
  60. fields = {}
  61. field_pattern = r'(\w+)\s*=\s*\{([^}]*)\}|(\w+)\s*=\s*"([^"]*)"'
  62. field_matches = re.finditer(field_pattern, fields_text)
  63. for field_match in field_matches:
  64. if field_match.group(1):
  65. field_name = field_match.group(1).lower()
  66. field_value = field_match.group(2)
  67. else:
  68. field_name = field_match.group(3).lower()
  69. field_value = field_match.group(4)
  70. fields[field_name] = field_value.strip()
  71. entries.append({
  72. 'type': entry_type,
  73. 'key': citation_key,
  74. 'fields': fields,
  75. 'raw': match.group(0)
  76. })
  77. return entries
  78. def validate_entry(self, entry: Dict) -> Tuple[List[Dict], List[Dict]]:
  79. """
  80. Validate a single BibTeX entry.
  81. Args:
  82. entry: Entry dictionary
  83. Returns:
  84. Tuple of (errors, warnings)
  85. """
  86. errors = []
  87. warnings = []
  88. entry_type = entry['type']
  89. key = entry['key']
  90. fields = entry['fields']
  91. # Check required fields
  92. if entry_type in self.required_fields:
  93. for req_field in self.required_fields[entry_type]:
  94. if req_field not in fields or not fields[req_field]:
  95. # Special case: book can have author OR editor
  96. if entry_type == 'book' and req_field == 'author':
  97. if 'editor' not in fields or not fields['editor']:
  98. errors.append({
  99. 'type': 'missing_required_field',
  100. 'field': 'author or editor',
  101. 'severity': 'high',
  102. 'message': f'Entry {key}: Missing required field "author" or "editor"'
  103. })
  104. else:
  105. errors.append({
  106. 'type': 'missing_required_field',
  107. 'field': req_field,
  108. 'severity': 'high',
  109. 'message': f'Entry {key}: Missing required field "{req_field}"'
  110. })
  111. # Check recommended fields
  112. if entry_type in self.recommended_fields:
  113. for rec_field in self.recommended_fields[entry_type]:
  114. if rec_field not in fields or not fields[rec_field]:
  115. warnings.append({
  116. 'type': 'missing_recommended_field',
  117. 'field': rec_field,
  118. 'severity': 'medium',
  119. 'message': f'Entry {key}: Missing recommended field "{rec_field}"'
  120. })
  121. # Validate year
  122. if 'year' in fields:
  123. year = fields['year']
  124. if not re.match(r'^\d{4}$', year):
  125. errors.append({
  126. 'type': 'invalid_year',
  127. 'field': 'year',
  128. 'value': year,
  129. 'severity': 'high',
  130. 'message': f'Entry {key}: Invalid year format "{year}" (should be 4 digits)'
  131. })
  132. elif int(year) < 1600 or int(year) > 2030:
  133. warnings.append({
  134. 'type': 'suspicious_year',
  135. 'field': 'year',
  136. 'value': year,
  137. 'severity': 'medium',
  138. 'message': f'Entry {key}: Suspicious year "{year}" (outside reasonable range)'
  139. })
  140. # Validate DOI format
  141. if 'doi' in fields:
  142. doi = fields['doi']
  143. if not re.match(r'^10\.\d{4,}/[^\s]+$', doi):
  144. warnings.append({
  145. 'type': 'invalid_doi_format',
  146. 'field': 'doi',
  147. 'value': doi,
  148. 'severity': 'medium',
  149. 'message': f'Entry {key}: Invalid DOI format "{doi}"'
  150. })
  151. # Check for single hyphen in pages (should be --)
  152. if 'pages' in fields:
  153. pages = fields['pages']
  154. if re.search(r'\d-\d', pages) and '--' not in pages:
  155. warnings.append({
  156. 'type': 'page_range_format',
  157. 'field': 'pages',
  158. 'value': pages,
  159. 'severity': 'low',
  160. 'message': f'Entry {key}: Page range uses single hyphen, should use -- (en-dash)'
  161. })
  162. # Check author format
  163. if 'author' in fields:
  164. author = fields['author']
  165. if ';' in author or '&' in author:
  166. errors.append({
  167. 'type': 'invalid_author_format',
  168. 'field': 'author',
  169. 'severity': 'high',
  170. 'message': f'Entry {key}: Authors should be separated by " and ", not ";" or "&"'
  171. })
  172. return errors, warnings
  173. def verify_doi(self, doi: str) -> Tuple[bool, Optional[Dict]]:
  174. """
  175. Verify DOI resolves correctly and get metadata.
  176. Args:
  177. doi: Digital Object Identifier
  178. Returns:
  179. Tuple of (is_valid, metadata)
  180. """
  181. try:
  182. url = f'https://doi.org/{doi}'
  183. response = self.session.head(url, timeout=10, allow_redirects=True)
  184. if response.status_code < 400:
  185. # DOI resolves, now get metadata from CrossRef
  186. crossref_url = f'https://api.crossref.org/works/{doi}'
  187. metadata_response = self.session.get(crossref_url, timeout=10)
  188. if metadata_response.status_code == 200:
  189. data = metadata_response.json()
  190. message = data.get('message', {})
  191. # Extract key metadata
  192. metadata = {
  193. 'title': message.get('title', [''])[0],
  194. 'year': self._extract_year_crossref(message),
  195. 'authors': self._format_authors_crossref(message.get('author', [])),
  196. }
  197. return True, metadata
  198. else:
  199. return True, None # DOI resolves but no CrossRef metadata
  200. else:
  201. return False, None
  202. except Exception:
  203. return False, None
  204. def detect_duplicates(self, entries: List[Dict]) -> List[Dict]:
  205. """
  206. Detect duplicate entries.
  207. Args:
  208. entries: List of entry dictionaries
  209. Returns:
  210. List of duplicate groups
  211. """
  212. duplicates = []
  213. # Check for duplicate DOIs
  214. doi_map = defaultdict(list)
  215. for entry in entries:
  216. doi = entry['fields'].get('doi', '').strip()
  217. if doi:
  218. doi_map[doi].append(entry['key'])
  219. for doi, keys in doi_map.items():
  220. if len(keys) > 1:
  221. duplicates.append({
  222. 'type': 'duplicate_doi',
  223. 'doi': doi,
  224. 'entries': keys,
  225. 'severity': 'high',
  226. 'message': f'Duplicate DOI {doi} found in entries: {", ".join(keys)}'
  227. })
  228. # Check for duplicate citation keys
  229. key_counts = defaultdict(int)
  230. for entry in entries:
  231. key_counts[entry['key']] += 1
  232. for key, count in key_counts.items():
  233. if count > 1:
  234. duplicates.append({
  235. 'type': 'duplicate_key',
  236. 'key': key,
  237. 'count': count,
  238. 'severity': 'high',
  239. 'message': f'Citation key "{key}" appears {count} times'
  240. })
  241. # Check for similar titles (possible duplicates)
  242. titles = {}
  243. for entry in entries:
  244. title = entry['fields'].get('title', '').lower()
  245. title = re.sub(r'[^\w\s]', '', title) # Remove punctuation
  246. title = ' '.join(title.split()) # Normalize whitespace
  247. if title:
  248. if title in titles:
  249. duplicates.append({
  250. 'type': 'similar_title',
  251. 'entries': [titles[title], entry['key']],
  252. 'severity': 'medium',
  253. 'message': f'Possible duplicate: "{titles[title]}" and "{entry["key"]}" have identical titles'
  254. })
  255. else:
  256. titles[title] = entry['key']
  257. return duplicates
  258. def validate_file(self, filepath: str, check_dois: bool = False) -> Dict:
  259. """
  260. Validate entire BibTeX file.
  261. Args:
  262. filepath: Path to BibTeX file
  263. check_dois: Whether to verify DOIs (slow)
  264. Returns:
  265. Validation report dictionary
  266. """
  267. print(f'Parsing {filepath}...', file=sys.stderr)
  268. entries = self.parse_bibtex_file(filepath)
  269. if not entries:
  270. return {
  271. 'total_entries': 0,
  272. 'errors': [],
  273. 'warnings': [],
  274. 'duplicates': []
  275. }
  276. print(f'Found {len(entries)} entries', file=sys.stderr)
  277. all_errors = []
  278. all_warnings = []
  279. # Validate each entry
  280. for i, entry in enumerate(entries):
  281. print(f'Validating entry {i+1}/{len(entries)}: {entry["key"]}', file=sys.stderr)
  282. errors, warnings = self.validate_entry(entry)
  283. for error in errors:
  284. error['entry'] = entry['key']
  285. all_errors.append(error)
  286. for warning in warnings:
  287. warning['entry'] = entry['key']
  288. all_warnings.append(warning)
  289. # Check for duplicates
  290. print('Checking for duplicates...', file=sys.stderr)
  291. duplicates = self.detect_duplicates(entries)
  292. # Verify DOIs if requested
  293. doi_errors = []
  294. if check_dois:
  295. print('Verifying DOIs...', file=sys.stderr)
  296. for i, entry in enumerate(entries):
  297. doi = entry['fields'].get('doi', '')
  298. if doi:
  299. print(f'Verifying DOI {i+1}: {doi}', file=sys.stderr)
  300. is_valid, metadata = self.verify_doi(doi)
  301. if not is_valid:
  302. doi_errors.append({
  303. 'type': 'invalid_doi',
  304. 'entry': entry['key'],
  305. 'doi': doi,
  306. 'severity': 'high',
  307. 'message': f'Entry {entry["key"]}: DOI does not resolve: {doi}'
  308. })
  309. all_errors.extend(doi_errors)
  310. return {
  311. 'filepath': filepath,
  312. 'total_entries': len(entries),
  313. 'valid_entries': len(entries) - len([e for e in all_errors if e['severity'] == 'high']),
  314. 'errors': all_errors,
  315. 'warnings': all_warnings,
  316. 'duplicates': duplicates
  317. }
  318. def _extract_year_crossref(self, message: Dict) -> str:
  319. """Extract year from CrossRef message."""
  320. date_parts = message.get('published-print', {}).get('date-parts', [[]])
  321. if not date_parts or not date_parts[0]:
  322. date_parts = message.get('published-online', {}).get('date-parts', [[]])
  323. if date_parts and date_parts[0]:
  324. return str(date_parts[0][0])
  325. return ''
  326. def _format_authors_crossref(self, authors: List[Dict]) -> str:
  327. """Format author list from CrossRef."""
  328. if not authors:
  329. return ''
  330. formatted = []
  331. for author in authors[:3]: # First 3 authors
  332. given = author.get('given', '')
  333. family = author.get('family', '')
  334. if family:
  335. formatted.append(f'{family}, {given}' if given else family)
  336. if len(authors) > 3:
  337. formatted.append('et al.')
  338. return ', '.join(formatted)
  339. def main():
  340. """Command-line interface."""
  341. parser = argparse.ArgumentParser(
  342. description='Validate BibTeX files for errors and inconsistencies',
  343. epilog='Example: python validate_citations.py references.bib'
  344. )
  345. parser.add_argument(
  346. 'file',
  347. help='BibTeX file to validate'
  348. )
  349. parser.add_argument(
  350. '--check-dois',
  351. action='store_true',
  352. help='Verify DOIs resolve correctly (slow)'
  353. )
  354. parser.add_argument(
  355. '--auto-fix',
  356. action='store_true',
  357. help='Attempt to auto-fix common issues (not implemented yet)'
  358. )
  359. parser.add_argument(
  360. '--report',
  361. help='Output file for JSON validation report'
  362. )
  363. parser.add_argument(
  364. '--verbose',
  365. action='store_true',
  366. help='Show detailed output'
  367. )
  368. args = parser.parse_args()
  369. # Validate file
  370. validator = CitationValidator()
  371. report = validator.validate_file(args.file, check_dois=args.check_dois)
  372. # Print summary
  373. print('\n' + '='*60)
  374. print('CITATION VALIDATION REPORT')
  375. print('='*60)
  376. print(f'\nFile: {args.file}')
  377. print(f'Total entries: {report["total_entries"]}')
  378. print(f'Valid entries: {report["valid_entries"]}')
  379. print(f'Errors: {len(report["errors"])}')
  380. print(f'Warnings: {len(report["warnings"])}')
  381. print(f'Duplicates: {len(report["duplicates"])}')
  382. # Print errors
  383. if report['errors']:
  384. print('\n' + '-'*60)
  385. print('ERRORS (must fix):')
  386. print('-'*60)
  387. for error in report['errors']:
  388. print(f'\n{error["message"]}')
  389. if args.verbose:
  390. print(f' Type: {error["type"]}')
  391. print(f' Severity: {error["severity"]}')
  392. # Print warnings
  393. if report['warnings'] and args.verbose:
  394. print('\n' + '-'*60)
  395. print('WARNINGS (should fix):')
  396. print('-'*60)
  397. for warning in report['warnings']:
  398. print(f'\n{warning["message"]}')
  399. # Print duplicates
  400. if report['duplicates']:
  401. print('\n' + '-'*60)
  402. print('DUPLICATES:')
  403. print('-'*60)
  404. for dup in report['duplicates']:
  405. print(f'\n{dup["message"]}')
  406. # Save report
  407. if args.report:
  408. with open(args.report, 'w', encoding='utf-8') as f:
  409. json.dump(report, f, indent=2)
  410. print(f'\nDetailed report saved to: {args.report}')
  411. # Exit with error code if there are errors
  412. if report['errors']:
  413. sys.exit(1)
  414. if __name__ == '__main__':
  415. main()