aivolcano commited on
Commit
350babd
·
0 Parent(s):

Initial commit

Browse files
.gitignore ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual Environments
25
+ venv/
26
+ env/
27
+ .env
28
+ .venv/
29
+
30
+ # IDEs
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+ *.swo
35
+
36
+ # macOS
37
+ .DS_Store
38
+ .AppleDouble
39
+ .LSOverride
40
+
41
+ # Project Specific Outputs
42
+ *.txt
43
+ *.md
44
+ !README.md
45
+ *_only_used_entry.bib
46
+
47
+ # LaTeX and Bibliography (User Data)
48
+ # Ignoring these to prevent committing personal paper content
49
+ *.tex
50
+ *.bib
51
+ *.pdf
52
+ *.log
53
+ *.aux
54
+ *.out
55
+ *.bbl
56
+ *.blg
57
+ *.synctex.gz
58
+ *.fls
59
+ *.fdb_latexmk
60
+
61
+ # cache
62
+ .cache
.gradio/certificate.pem ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
3
+ TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
4
+ cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
5
+ WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
6
+ ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
7
+ MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
8
+ h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
9
+ 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
10
+ A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
11
+ T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
12
+ B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
13
+ B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
14
+ KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
15
+ OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
16
+ jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
17
+ qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
18
+ rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
19
+ HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
20
+ hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
21
+ ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
22
+ 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
23
+ NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
24
+ ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
25
+ TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
26
+ jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
27
+ oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
28
+ 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
29
+ mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
30
+ emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
31
+ -----END CERTIFICATE-----
README.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CiteScan: Check References, Confirm Truth.
2
+
3
+ <!-- - **🎉 CiteScan is now available online. [https://huggingface.co/spaces/thinkwee/CiteScan](https://huggingface.co/spaces/thinkwee/CiteScan)! 🎉** -->
4
+
5
+
6
+ **CiteScan** is an open-source and free tool designed to detect hallucinated references in academic writing. As AI coding assistants and writing tools become more prevalent, they sometimes generate plausible-sounding citations that do not actually exist. **CiteScan** addresses this issue by validating every bibliography entry against multiple authoritative academic databases—including arXiv, CrossRef, DBLP, Semantic Scholar, OpenAlex, and Google Scholar—to confirm their authenticity.
7
+
8
+ Going beyond simple verification, **CiteScan** used rule-based algorithms to analyze whether the cited papers genuinely support the claims made in your text. Thanks to the free accessibility for academic databases across CS and AI areas, our system will **cost $0 for maintenance after development**.
9
+
10
+
11
+ ## 🛡 Why CiteScan?
12
+
13
+ - **🚫 NO Hallucinations**: Annotate citations that don't exist or have mismatched metadata across year, authors, and title.
14
+
15
+ - **📋 Ground Truth Reference**: Provide the link if the citations are flagged to *issued entry*. You can click the **Open paper** or **DOI** button to access the real-world metadata, and Then cite the BibTex from the press website.
16
+
17
+ ![Functions](assets/screenshot_performance_zh.png)
18
+
19
+ - **🏠 Top-tier Research Organizations**: Cooperate with National University of Singapore (NUS) and Shanghai Jiao Tong University (SJTU).
20
+
21
+ ## References Validation
22
+ - **Multi-Source Verification**: Validates metadata against arXiv, CrossRef, DBLP, Semantic Scholar, OpenAlex, and Google Scholar
23
+
24
+
25
+
26
+ ![Citation](assets/screenshot_semantic_scholar.png)
27
+
28
+
29
+ ### Understanding Mismatches
30
+
31
+ *False positive cases* occur for CiteScan:
32
+
33
+ 1. **Authors Mismatch**:
34
+ - *Reason*: Different databases deal with a longer list of authors with different strategies, like truncation.
35
+ - *Action*: Verify if main authors match
36
+
37
+ 2. **Venues Mismatch**:
38
+ - *Reason*: Abbreviations vs. full names, such as "ICLR" v.s. "International Conference on Learning Representations"
39
+ - *Action*: Both are correct.
40
+
41
+ 3. **Year GAP (±1 Year)**:
42
+ - *Reason*: Delay between preprint (arXiv) and final version publication
43
+ - *Action*: Verify which version you intend to cite, We recommend you to cite the version from the official press website. Lower pre-print version bib will make your submission more confidence.
44
+
45
+ 4. **Non-academic Sources**:
46
+ - *Reason*: Blogs, and APIs are not indexed in academic databases.
47
+ - *Action*: Verify URL, year, and title manually.
48
+
49
+
50
+ ## 🙏 Acknowledgments
51
+
52
+ CiteScan uses multiple data sources:
53
+ - arXiv API
54
+ - CrossRef API
55
+ - Semantic Scholar API
56
+ - DBLP API
57
+ - OpenAlex API
58
+
59
+
60
+ ### Push
61
+ ```shell
62
+
63
+ # 如果已经添加过 modelscope 远程,先删掉再加:
64
+ git remote remove modelscope
65
+ git remote add modelscope "http://oauth2:ms-28735aa7-04b6-4b21-b4e0-cfb464f3587f@www.modelscope.cn/studios/aivolcano/CiteScan.git"
66
+
67
+ # 添加 ModelScope 为远程仓库
68
+ git remote add modelscope "http://oauth2:ms-28735aa7-04b6-4b21-b4e0-cfb464f3587f@www.modelscope.cn/studios/aivolcano/CiteScan.git"
69
+
70
+ # 推送到创空间(主分支是 main 时)
71
+ git push modelscope main
72
+
73
+ # 推送到创空间(主分支是 master 时)
74
+ git push modelscope master
75
+
76
+
77
+ # 若推送时报错
78
+ git push modelscope main --force
79
+
80
+ # 推送成功后
81
+ https://www.modelscope.cn/studios/aivolcano/CiteScan
82
+ # 创空间里点 「上线空间展示」 或 「立即发布」,等部署完成即可访问 Gradio 应用。
83
+ ```
84
+
Untitled ADDED
@@ -0,0 +1 @@
 
 
1
+ citescan.yaml
app.py ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from tkinter.constants import TRUE
3
+ import base64
4
+ import gradio as gr
5
+ import tempfile
6
+ from pathlib import Path
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ import threading
9
+
10
+ from src.parsers import BibParser
11
+ from src.fetchers import ArxivFetcher, ScholarFetcher, CrossRefFetcher, SemanticScholarFetcher, OpenAlexFetcher, DBLPFetcher
12
+ from src.analyzers import MetadataComparator, DuplicateDetector
13
+ from src.report.generator import EntryReport
14
+ from src.config.workflow import get_default_workflow
15
+
16
+
17
+ def format_entry_card(entry_report, duplicate_groups=None):
18
+ """格式化单个文献条目为 HTML 卡片"""
19
+ entry = entry_report.entry
20
+ comparison = entry_report.comparison
21
+
22
+ # 判断状态
23
+ if comparison and comparison.is_match:
24
+ card_class = "verified"
25
+ status_icon = "✓"
26
+ status_text = "Verified"
27
+ elif comparison and comparison.has_issues:
28
+ card_class = "warning"
29
+ status_icon = "⚠️"
30
+ status_text = "Issues Found"
31
+ else:
32
+ card_class = "error"
33
+ status_icon = "✗"
34
+ status_text = "Not Found"
35
+
36
+ # 格式化 BibTeX(BibEntry 用 raw_entry 存原始字段)
37
+ bibtex_str = f"@{entry.entry_type}{{{entry.key},\n"
38
+ for field, value in (entry.raw_entry or {}).items():
39
+ if field in ("ID", "ENTRYTYPE"):
40
+ continue
41
+ if value is not None and str(value).strip():
42
+ bibtex_str += f" {field}={{{value}}},\n"
43
+ bibtex_str = bibtex_str.rstrip(",\n") + "\n}"
44
+
45
+ # Link for header (Open paper / DOI) when we have reference
46
+ link_url = ""
47
+ link_label = "Open paper"
48
+ if comparison and getattr(comparison, "source", "") != "unable":
49
+ if getattr(comparison, "fetched_doi", None) and str(comparison.fetched_doi).strip():
50
+ link_url = "https://doi.org/" + str(comparison.fetched_doi).strip()
51
+ link_label = "DOI"
52
+ elif getattr(comparison, "fetched_url", None) and str(comparison.fetched_url).strip():
53
+ link_url = str(comparison.fetched_url).strip()
54
+
55
+ # 收集标签
56
+ tags = []
57
+ if comparison:
58
+ if comparison.is_match:
59
+ tags.append(('<span class="tag success">✓ Verified</span>', 0))
60
+ if comparison.source:
61
+ tags.append((f'<span class="tag info">Source: {comparison.source}</span>', 0))
62
+
63
+ # 问题标签(ComparisonResult 使用 *_match,用 not *_match 表示 mismatch)
64
+ if not comparison.title_match:
65
+ tags.append(('<span class="tag warning">⚠️ Title Mismatch</span>', 1))
66
+ if not comparison.author_match:
67
+ tags.append(('<span class="tag warning">⚠️ Author Mismatch</span>', 1))
68
+ if not comparison.year_match:
69
+ tags.append(('<span class="tag warning">⚠️ Year Mismatch</span>', 1))
70
+ if hasattr(comparison, 'venue_match') and not comparison.venue_match:
71
+ tags.append(('<span class="tag warning">⚠️ Venue Mismatch</span>', 1))
72
+ if not comparison.is_match and not comparison.has_issues:
73
+ tags.append(('<span class="tag error">✗ Not Found</span>', 2))
74
+
75
+ # 检查是否是重复条目
76
+ if duplicate_groups:
77
+ for group in duplicate_groups:
78
+ if entry.key in group.entry_keys:
79
+ tags.append(('<span class="tag warning">⚠️ Duplicate Entry</span>', 1))
80
+ break
81
+
82
+ # 按优先级排序标签
83
+ tags.sort(key=lambda x: x[1])
84
+ tags_html = '\n'.join([tag[0] for tag in tags])
85
+
86
+ # 详细信息
87
+ metadata_info = ""
88
+ if comparison:
89
+ if comparison.is_match:
90
+ confidence = getattr(comparison, 'confidence', 0)
91
+ metadata_info = f"<strong>Verification Info:</strong> All fields matched successfully | Confidence: {confidence * 100:.2f}%"
92
+ elif comparison.has_issues:
93
+ issues = []
94
+ if not comparison.title_match:
95
+ issues.append("• Title mismatch detected")
96
+ if not comparison.author_match:
97
+ issues.append("• Author list differs from database")
98
+ if not comparison.year_match:
99
+ issues.append("• Publication year mismatch")
100
+ if hasattr(comparison, 'venue_match') and not comparison.venue_match:
101
+ issues.append("• Venue/journal name differs")
102
+ metadata_info = f"<strong>Issue Details:</strong><br>" + "<br>".join(issues)
103
+ else:
104
+ metadata_info = f"""<strong>Issue Details:</strong><br>
105
+ • Entry not found in any database<br>
106
+ • Possible causes: incorrect title, author errors, or non-existent reference<br>
107
+ • Suggestion: verify the original source or use a search engine"""
108
+
109
+ # Ground truth (reference): compact title, author, year, doi only (no Copy, no full BibTeX)
110
+ fetched_bibtex_html = ""
111
+ if comparison and getattr(comparison, "source", "") != "unable" and (
112
+ getattr(comparison, "fetched_title", None) or getattr(comparison, "fetched_authors", None)
113
+ ):
114
+ src = getattr(comparison, "source", "reference")
115
+ fa = getattr(comparison, "fetched_authors", None)
116
+ authors_str = " and ".join(fa) if isinstance(fa, list) else (fa or "")
117
+ ft = (getattr(comparison, "fetched_title", None) or "").strip()
118
+ fy = (getattr(comparison, "fetched_year", None) or "").strip()
119
+ fdoi = (getattr(comparison, "fetched_doi", None) or "").strip()
120
+
121
+ def _line(label, value):
122
+ if not value:
123
+ return ""
124
+ esc = (value or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
125
+ return f'<div style="margin: 0.15em 0; font-size: 0.9em;"><strong>{label}:</strong> {esc}</div>'
126
+
127
+ rows = []
128
+ if ft:
129
+ rows.append(_line("Title", ft))
130
+ if authors_str:
131
+ rows.append(_line("Authors", authors_str))
132
+ if fy:
133
+ rows.append(_line("Year", fy))
134
+ if fdoi:
135
+ rows.append(_line("DOI", fdoi))
136
+ fetched_bibtex_html = f"""
137
+ <div class="metadata-info" style="margin-top: 0.5em; font-size: 0.95em;">
138
+ <strong>Reference (from {src}):</strong>
139
+ {"".join(rows)}
140
+ </div>"""
141
+
142
+ header_link_html = ""
143
+ if link_url:
144
+ header_link_html = (
145
+ f'<a href="{link_url}" target="_blank" rel="noopener" '
146
+ 'style="margin-left: auto; padding: 0.35em 0.75em; background: #2563eb; color: white; border-radius: 6px; font-size: 0.9em; font-weight: 500; text-decoration: none;">'
147
+ f'{link_label}</a>'
148
+ )
149
+
150
+ html = f"""
151
+ <div class="entry-card {card_class}">
152
+ <div class="entry-header" style="display: flex; align-items: center; gap: 0.5em; flex-wrap: wrap;">
153
+ <span class="status-icon">{status_icon}</span>
154
+ <span class="entry-key">{entry.key}</span>
155
+ {header_link_html}
156
+ </div>
157
+ <div class="bibtex-content">{bibtex_str}</div>
158
+ <div class="tags-container">
159
+ {tags_html}
160
+ </div>
161
+ <div class="metadata-info">
162
+ {metadata_info}
163
+ </div>
164
+ {fetched_bibtex_html}
165
+ </div>
166
+ """
167
+ return html, card_class
168
+
169
+
170
+ def get_card_class(entry_report):
171
+ """Return 'verified' | 'warning' | 'error' for filtering."""
172
+ comparison = entry_report.comparison
173
+ if comparison and comparison.is_match:
174
+ return "verified"
175
+ if comparison and comparison.has_issues:
176
+ return "warning"
177
+ return "error"
178
+
179
+
180
+ FILTER_TO_CLASS = {"Total": None, "Verified": "verified", "Issues Found": "warning", "Not Found": "error"}
181
+
182
+ # CSS 样式(供 render_results 使用)
183
+ REPORT_CSS = """
184
+ <style>
185
+ .ci te scan-container { max-width: 1200px; margin: 0 auto; }
186
+ .entry-card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin-bottom: 20px; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
187
+ .entry-card.verified { border-left: 4px solid #4caf50; }
188
+ .entry-card.warning { border-left: 4px solid #ff9800; }
189
+ .entry-card.error { border-left: 4px solid #f44336; }
190
+ .entry-header { display: flex; align-items: center; margin-bottom: 15px; }
191
+ .status-icon { font-size: 24px; margin-right: 10px; }
192
+ .entry-key { font-size: 18px; font-weight: bold; color: #333; }
193
+ .bibtex-content { background: #f5f5f5; padding: 15px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 13px; margin: 15px 0; overflow-x: auto; white-space: pre-wrap; color: #1a1a1a; }
194
+ .tags-container { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 15px; }
195
+ .tag { padding: 6px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; }
196
+ .tag.success { background: #e8f5e9; color: #2e7d32; }
197
+ .tag.warning { background: #fff3e0; color: #e65100; }
198
+ .tag.error { background: #ffebee; color: #c62828; }
199
+ .tag.info { background: #e3f2fd; color: #1565c0; }
200
+ .metadata-info { margin-top: 10px; padding: 10px; background: #fafafa; border-radius: 4px; font-size: 13px; color: #666; }
201
+ .summary-stats { background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 30px; display: flex; justify-content: space-around; text-align: center; }
202
+ .stat-item { flex: 1; }
203
+ .stat-number { font-size: 32px; font-weight: bold; color: #333; }
204
+ .stat-label { font-size: 14px; color: #666; margin-top: 5px; }
205
+ @media (prefers-color-scheme: dark) {
206
+ .citescan-container { color: #e4e4e7; }
207
+ .entry-card { background: #27272a; border-color: #3f3f46; box-shadow: 0 2px 4px rgba(0,0,0,0.3); }
208
+ .entry-key { color: #fafafa; }
209
+ .bibtex-content { background: #18181b; color: #d4d4d8; }
210
+ .tag.success { background: #166534; color: #bbf7d0; }
211
+ .tag.warning { background: #9a3412; color: #fed7aa; }
212
+ .tag.error { background: #991b1b; color: #fecaca; }
213
+ .tag.info { background: #1e3a8a; color: #bfdbfe; }
214
+ .metadata-info { background: #3f3f46; color: #a1a1aa; }
215
+ .metadata-info strong { color: #e4e4e7; }
216
+ .summary-stats { background: #3f3f46; color: #e4e4e7; }
217
+ .stat-number { color: #fafafa; }
218
+ .stat-label { color: #a1a1aa; }
219
+ }
220
+ </style>
221
+ """
222
+
223
+
224
+ def render_results(entry_reports, duplicate_groups, filter_choice, include_summary=True):
225
+ """Build HTML for (optionally) summary + filtered cards. include_summary=False when bar is a separate Gradio component."""
226
+ verified_count = warning_count = error_count = 0
227
+ for er in entry_reports:
228
+ c = get_card_class(er)
229
+ if c == "verified":
230
+ verified_count += 1
231
+ elif c == "warning":
232
+ warning_count += 1
233
+ else:
234
+ error_count += 1
235
+
236
+ summary_html = ""
237
+ if include_summary:
238
+ summary_html = f"""
239
+ <div class="summary-stats">
240
+ <div class="stat-item"><div class="stat-number" style="color: #4caf50;">{verified_count}</div><div class="stat-label">✓ Verified</div></div>
241
+ <div class="stat-item"><div class="stat-number" style="color: #ff9800;">{warning_count}</div><div class="stat-label">⚠️ Issues Found</div></div>
242
+ <div class="stat-item"><div class="stat-number" style="color: #f44336;">{error_count}</div><div class="stat-label">✗ Not Found</div></div>
243
+ <div class="stat-item"><div class="stat-number">{len(entry_reports)}</div><div class="stat-label">Total</div></div>
244
+ </div>
245
+ """
246
+
247
+ want_class = FILTER_TO_CLASS.get(filter_choice)
248
+ if want_class is None:
249
+ filtered = entry_reports
250
+ else:
251
+ filtered = [er for er in entry_reports if get_card_class(er) == want_class]
252
+
253
+ cards_html = ""
254
+ for entry_report in filtered:
255
+ card_html, _ = format_entry_card(entry_report, duplicate_groups)
256
+ cards_html += card_html
257
+ if not cards_html:
258
+ cards_html = "<p style='color: #666; margin: 1em 0;'>No entries in this category.</p>"
259
+
260
+ return f"{REPORT_CSS}<div class='citescan-container'>{summary_html}{cards_html}</div>"
261
+
262
+
263
+ def filter_display(state, filter_choice):
264
+ """Re-render results with filter. state = (entry_reports, duplicate_groups) or None."""
265
+ if state is None:
266
+ return "<p style='color: #666;'>Please run Verify first.</p>"
267
+ entry_reports, duplicate_groups = state
268
+ return render_results(entry_reports, duplicate_groups, filter_choice, include_summary=False)
269
+
270
+
271
+ # Bar 单段 HTML(大数字 + 小标签),配色与图片一致
272
+ def _bar_segment_html(num, label, num_color):
273
+ return f'<div class="bar-seg"><span class="bar-num" style="color:{num_color}">{num}</span><span class="bar-label">{label}</span></div>'
274
+
275
+
276
+ def bar_segments_html(verified_count, warning_count, error_count, total):
277
+ """返回 4 段 (Verified, Issues Found, Not Found, Total) 的 HTML,用于图片式 bar。"""
278
+ return (
279
+ _bar_segment_html(verified_count, "✓ Verified", "#32CD32"),
280
+ _bar_segment_html(warning_count, "⚠️ Issues Found", "#FFA500"),
281
+ _bar_segment_html(error_count, "✗ Not Found", "#FF0000"),
282
+ _bar_segment_html(total, "Total", "#ffffff"),
283
+ )
284
+
285
+
286
+ def process_bibtex(bibtex_input, progress=gr.Progress()):
287
+ """处理用户输入的 BibTeX 并进行检测。返回 (html, state, seg1, seg2, seg3, seg4) 供 bar 展示与筛选。"""
288
+ zero_segs = bar_segments_html(0, 0, 0, 0)
289
+ if not bibtex_input.strip():
290
+ return "<p style='color: red;'>Please enter BibTeX content</p>", None, *zero_segs
291
+
292
+ try:
293
+ # 解析 BibTeX
294
+ progress(0, desc="Parsing BibTeX...")
295
+ parser = BibParser()
296
+
297
+ # 写入临时文件
298
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.bib', delete=False) as f:
299
+ f.write(bibtex_input)
300
+ temp_bib_path = f.name
301
+
302
+ entries = parser.parse_file(temp_bib_path)
303
+ Path(temp_bib_path).unlink() # 删除临时文件
304
+
305
+ if not entries:
306
+ return "<p style='color: red;'>No valid BibTeX entries found</p>", None, *bar_segments_html(0, 0, 0, 0)
307
+
308
+ # 初始化检测器
309
+ progress(0.1, desc="Initializing fetchers...")
310
+ arxiv_fetcher = ArxivFetcher()
311
+ crossref_fetcher = CrossRefFetcher()
312
+ scholar_fetcher = ScholarFetcher()
313
+ semantic_scholar_fetcher = SemanticScholarFetcher()
314
+ openalex_fetcher = OpenAlexFetcher()
315
+ dblp_fetcher = DBLPFetcher()
316
+ comparator = MetadataComparator()
317
+ duplicate_detector = DuplicateDetector()
318
+
319
+ # 检测重复
320
+ duplicate_groups = duplicate_detector.find_duplicates(entries)
321
+
322
+ # 获取工作流
323
+ workflow_config = get_default_workflow()
324
+
325
+ # 处理每个条目
326
+ entry_reports = []
327
+ progress_lock = threading.Lock()
328
+ verified_count = 0
329
+ warning_count = 0
330
+ error_count = 0
331
+
332
+ def process_single_entry(entry, idx, total):
333
+ from src.utils.normalizer import TextNormalizer
334
+
335
+ comparison_result = None
336
+ all_results = []
337
+
338
+ for step in workflow_config.get_enabled_steps():
339
+ result = None
340
+ if step.name == "arxiv_id" and entry.has_arxiv and arxiv_fetcher:
341
+ arxiv_meta = arxiv_fetcher.fetch_by_id(entry.arxiv_id)
342
+ if arxiv_meta:
343
+ result = comparator.compare_with_arxiv(entry, arxiv_meta)
344
+ elif step.name == "crossref_doi" and entry.doi and crossref_fetcher:
345
+ crossref_result = crossref_fetcher.search_by_doi(entry.doi)
346
+ if crossref_result:
347
+ result = comparator.compare_with_crossref(entry, crossref_result)
348
+ elif step.name == "semantic_scholar" and entry.title and semantic_scholar_fetcher:
349
+ ss_result = semantic_scholar_fetcher.fetch_by_doi(entry.doi) if entry.doi else None
350
+ if not ss_result:
351
+ ss_result = semantic_scholar_fetcher.search_by_title(entry.title)
352
+ if ss_result:
353
+ result = comparator.compare_with_semantic_scholar(entry, ss_result)
354
+ elif step.name == "dblp" and entry.title and dblp_fetcher:
355
+ dblp_result = dblp_fetcher.search_by_title(entry.title)
356
+ if dblp_result:
357
+ result = comparator.compare_with_dblp(entry, dblp_result)
358
+ elif step.name == "openalex" and entry.title and openalex_fetcher:
359
+ oa_result = openalex_fetcher.fetch_by_doi(entry.doi) if entry.doi else None
360
+ if not oa_result:
361
+ oa_result = openalex_fetcher.search_by_title(entry.title)
362
+ if oa_result:
363
+ result = comparator.compare_with_openalex(entry, oa_result)
364
+ elif step.name == "arxiv_title" and entry.title and arxiv_fetcher:
365
+ results = arxiv_fetcher.search_by_title(entry.title, max_results=3)
366
+ if results:
367
+ best_result = None
368
+ best_sim = 0.0
369
+ norm1 = TextNormalizer.normalize_for_comparison(entry.title)
370
+ for r in results:
371
+ sim = TextNormalizer.similarity_ratio(norm1, TextNormalizer.normalize_for_comparison(r.title))
372
+ if sim > best_sim:
373
+ best_sim, best_result = sim, r
374
+ if best_result and best_sim > 0.5:
375
+ result = comparator.compare_with_arxiv(entry, best_result)
376
+ elif step.name == "crossref_title" and entry.title and crossref_fetcher:
377
+ crossref_result = crossref_fetcher.search_by_title(entry.title)
378
+ if crossref_result:
379
+ result = comparator.compare_with_crossref(entry, crossref_result)
380
+ elif step.name == "google_scholar" and entry.title and scholar_fetcher:
381
+ scholar_result = scholar_fetcher.search_by_title(entry.title)
382
+ if scholar_result:
383
+ result = comparator.compare_with_scholar(entry, scholar_result)
384
+
385
+ if result:
386
+ all_results.append(result)
387
+ if result.is_match:
388
+ comparison_result = result
389
+ break
390
+
391
+ if not comparison_result and all_results:
392
+ all_results.sort(key=lambda r: r.confidence, reverse=True)
393
+ comparison_result = all_results[0]
394
+ elif not comparison_result:
395
+ comparison_result = comparator.create_unable_result(entry, "Unable to find this paper in any data source")
396
+
397
+ return EntryReport(entry=entry, comparison=comparison_result)
398
+
399
+ max_workers = min(10, len(entries))
400
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
401
+ future_to_entry = {executor.submit(process_single_entry, e, i, len(entries)): (e, i) for i, e in enumerate(entries)}
402
+
403
+ for future in as_completed(future_to_entry):
404
+ entry, idx = future_to_entry[future]
405
+ try:
406
+ entry_report = future.result()
407
+ with progress_lock:
408
+ entry_reports.append(entry_report)
409
+
410
+ if entry_report.comparison and entry_report.comparison.is_match:
411
+ verified_count += 1
412
+ elif entry_report.comparison and entry_report.comparison.has_issues:
413
+ warning_count += 1
414
+ else:
415
+ error_count += 1
416
+
417
+ progress((idx + 1) / len(entries), desc=f"Verifying entries {idx + 1}/{len(entries)}...")
418
+ except Exception as e:
419
+ with progress_lock:
420
+ error_count += 1
421
+ print(f"Error processing {entry.key}: {e}")
422
+
423
+ # 生成 HTML(默认 Total 视图,不含 bar),并保存结果供分类筛选
424
+ progress(1.0, desc="Generating report...")
425
+ final_html = render_results(entry_reports, duplicate_groups, "Total", include_summary=False)
426
+ segs = bar_segments_html(verified_count, warning_count, error_count, len(entry_reports))
427
+ return final_html, (entry_reports, duplicate_groups), *segs
428
+
429
+ except Exception as e:
430
+ import traceback
431
+ error_msg = f"<p style='color: red;'>Error: {str(e)}</p><pre>{traceback.format_exc()}</pre>"
432
+ return error_msg, None, *bar_segments_html(0, 0, 0, 0)
433
+
434
+
435
+ # 官网示例:点击即可填入输入框进行测试
436
+ BIBTEX_EXAMPLES = [
437
+ (
438
+ """@article{gpt2,
439
+ title={Language models are unsupervised multitask},
440
+ author={Radford, Alec and Child, Rewon and Luan, David and Amodei, Dario and Sutskever, Ilya and others},
441
+ journal={OpenAI blog},
442
+ volume={1},
443
+ number={8},
444
+ pages={9},
445
+ year={2021}
446
+ }""",
447
+ "GPT-2 (OpenAI blog)",
448
+ ),
449
+ (
450
+ """@article{devlin2018bert,
451
+ year={2018},
452
+ journal={arXiv preprint arXiv:1810.04805},
453
+ author={Devlin, Jacob and Chang, Ming-Wei and Lee, Kenton and Toutanova, Kristina},
454
+ title={BERT: Pre-training of deep bidirectional transformers for language understanding}
455
+ }""",
456
+ "BERT (arXiv)",
457
+ ),
458
+ (
459
+ """@article{vaswani2017attention,
460
+ title={Attention is all you need},
461
+ author={Vaswani, Ashish and Shazeer, Noam and others},
462
+ journal={Advances in neural information processing systems},
463
+ year={2017}
464
+ }
465
+
466
+ @article{brown2020language,
467
+ title={Language models are few-shot learners},
468
+ author={Brown, Tom B and Mann, Benjamin and others},
469
+ year={2020}
470
+ }""",
471
+ "Attention + GPT-3 (multiple entries)",
472
+ ),
473
+ ]
474
+
475
+ # Bar 图片式 UI:深灰背景 #2E3035,大数字 + 小标签,绿/橙/红/白
476
+ BAR_CSS = """
477
+ .status-bar-row { background: #2E3035 !important; border-radius: 8px !important; padding: 20px !important; margin-bottom: 20px !important; display: flex !important; justify-content: space-around !important; align-items: stretch !important; gap: 12px !important; }
478
+ .bar-segment-col { flex: 1 !important; text-align: center !important; position: relative !important; min-width: 0 !important; }
479
+ .bar-segment-col .bar-seg { display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; padding: 8px 4px !important; }
480
+ .bar-segment-col .bar-num { font-size: 32px !important; font-weight: bold !important; line-height: 1.2 !important; display: block !important; }
481
+ .bar-segment-col .bar-label { font-size: 13px !important; color: #ffffff !important; margin-top: 4px !important; display: block !important; }
482
+ .bar-segment-btn { position: absolute !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; opacity: 0 !important; cursor: pointer !important; }
483
+ """
484
+
485
+ # 创建 Gradio 界面
486
+ with gr.Blocks(title="CiteScan - Check References, Confirm Truth.", theme=gr.themes.Soft(), css=BAR_CSS) as demo:
487
+ gr.Markdown("""
488
+ # CiteScan - Check References, Confirm Truth.
489
+
490
+ 1️⃣ Paste your BibTeX below, or **click an example** to load it.
491
+
492
+ 2️⃣ Click "Verify" button to have the system verify the authenticity and accuracy of each reference.
493
+
494
+ **Important: We check very carefully. Sometimes the result might look different from Google Scholar or ArXiv. We think it's best to use the official version from places like ACM, ACL, or CVF to cite your sources.
495
+
496
+ We will also add a feature soon to help change citations from pre-print versions (like arXiv or bioRxiv) into the final official ones (like from NeurIPS, ACL, or CVF).**
497
+ """)
498
+
499
+ with gr.Row():
500
+ with gr.Column():
501
+ bibtex_input = gr.Textbox(
502
+ label="📝 Paste your BibTeX",
503
+ placeholder="""Paste your BibTeX or click an example below. for example:
504
+ @article{gpt2,
505
+ title={Language models are unsupervised multitask},
506
+ author={Radford, Alec and Child, Rewon and Luan, David and Amodei, Dario and Sutskever, Ilya and others},
507
+ journal={OpenAI blog},
508
+ volume={1},
509
+ number={8},
510
+ pages={9},
511
+ year={2021}
512
+ }
513
+
514
+ @article{devlin2018bert,
515
+ year={2018},
516
+ journal={arXiv preprint arXiv:1810.04805},
517
+ author={Devlin, Jacob and Chang, Ming-Wei and Lee, Kenton and Toutanova, Kristina},
518
+ title={BERT: Pre-training of deep bidirectional transformers for language understanding}
519
+ }
520
+ """,
521
+ lines=15,
522
+ max_lines=20
523
+ )
524
+
525
+ submit_btn = gr.Button("🚀 Verify", variant="primary", size="lg")
526
+
527
+ gr.Examples(
528
+ examples=[[ex[0]] for ex in BIBTEX_EXAMPLES],
529
+ inputs=[bibtex_input],
530
+ label="📋 Examples (click to load)",
531
+ examples_per_page=6,
532
+ )
533
+
534
+ result_state = gr.State(value=None)
535
+
536
+ # Bar:图片式 UI(深灰 #2E3035,大数字 + 小标签),每段可点击筛选
537
+ zero_segs = bar_segments_html(0, 0, 0, 0)
538
+ with gr.Row(elem_classes=["status-bar-row"]):
539
+ with gr.Column(elem_classes=["bar-segment-col"], scale=1):
540
+ bar_seg_verified = gr.HTML(zero_segs[0])
541
+ btn_verified = gr.Button("Verified", elem_classes=["bar-segment-btn"], visible=True)
542
+ with gr.Column(elem_classes=["bar-segment-col"], scale=1):
543
+ bar_seg_issues = gr.HTML(zero_segs[1])
544
+ btn_issues = gr.Button("Issues", elem_classes=["bar-segment-btn"], visible=True)
545
+ with gr.Column(elem_classes=["bar-segment-col"], scale=1):
546
+ bar_seg_notfound = gr.HTML(zero_segs[2])
547
+ btn_notfound = gr.Button("Not Found", elem_classes=["bar-segment-btn"], visible=True)
548
+ with gr.Column(elem_classes=["bar-segment-col"], scale=1):
549
+ bar_seg_total = gr.HTML(zero_segs[3])
550
+ btn_total = gr.Button("Total", elem_classes=["bar-segment-btn"], visible=True)
551
+
552
+ with gr.Row():
553
+ output_html = gr.HTML(label="Detection Results")
554
+
555
+ submit_btn.click(
556
+ fn=process_bibtex,
557
+ inputs=[bibtex_input],
558
+ outputs=[output_html, result_state, bar_seg_verified, bar_seg_issues, bar_seg_notfound, bar_seg_total],
559
+ )
560
+
561
+ def filter_to_verified(state):
562
+ return filter_display(state, "Verified")
563
+
564
+ def filter_to_issues(state):
565
+ return filter_display(state, "Issues Found")
566
+
567
+ def filter_to_notfound(state):
568
+ return filter_display(state, "Not Found")
569
+
570
+ def filter_to_total(state):
571
+ return filter_display(state, "Total")
572
+
573
+ btn_verified.click(fn=filter_to_verified, inputs=[result_state], outputs=[output_html])
574
+ btn_issues.click(fn=filter_to_issues, inputs=[result_state], outputs=[output_html])
575
+ btn_notfound.click(fn=filter_to_notfound, inputs=[result_state], outputs=[output_html])
576
+ btn_total.click(fn=filter_to_total, inputs=[result_state], outputs=[output_html])
577
+
578
+ gr.Markdown("""
579
+ *False positive cases* occur for CiteScan:
580
+
581
+ 1. **Authors Mismatch**:
582
+ - *Reason*: Different databases deal with a longer list of authors with different strategies, like truncation.
583
+ - *Action*: Verify if main authors match
584
+
585
+ 2. **Venues Mismatch**:
586
+ - *Reason*: Abbreviations vs. full names, such as "ICLR" v.s. "International Conference on Learning Representations"
587
+ - *Action*: Both are correct.
588
+
589
+ 3. **Year GAP (±1 Year)**:
590
+ - *Reason*: Delay between preprint (arXiv) and final version publication
591
+ - *Action*: Verify which version you intend to cite, We recommend you to cite the version from the official press website. Lower pre-print version bib will make your submission more confidence.
592
+
593
+ 4. **Non-academic Sources**:
594
+ - *Reason*: Blogs, and APIs are not indexed in academic databases.
595
+ - *Action*: Verify URL, year, and title manually.
596
+ ---
597
+ **Supported Data Sources:** arXiv, CrossRef, DBLP, Semantic Scholar, ACL Anthology, ACM, theCVF,
598
+ """)
599
+
600
+ # Partner logos and contact (embed images as base64 so they work when served)
601
+ _root = Path(__file__).resolve().parent
602
+ def _logo_b64(path: Path) -> str | None:
603
+ if path.exists():
604
+ return base64.b64encode(path.read_bytes()).decode("utf-8")
605
+ return None
606
+ _nus = _logo_b64(_root / "assets" / "logo_nus.png")
607
+ _sjtu = _logo_b64(_root / "assets" / "logo_sjtu.png")
608
+ _logos_html = []
609
+ if _nus:
610
+ _logos_html.append(f'<img src="data:image/png;base64,{_nus}" alt="NUS" style="height:72px; margin-right:24px; vertical-align:middle; display:inline-block;" />')
611
+ if _sjtu:
612
+ _logos_html.append(f'<img src="data:image/png;base64,{_sjtu}" alt="Shanghai Jiao Tong University" style="height:72px; vertical-align:middle; display:inline-block;" />')
613
+ gr.HTML(f"""
614
+ <div style="margin-top:12px;">
615
+ <p><strong>Cooperations</strong></p>
616
+ <p style="display:flex; align-items:center; gap:24px; flex-wrap:nowrap;">{" ".join(_logos_html)}</p>
617
+ <p><strong> Feel free to reach out me by Email</strong> <a href="mailto:e1143641@u.nus.edu">e1143641@u.nus.edu</a></p>
618
+ </div>
619
+ """)
620
+
621
+ if __name__ == "__main__":
622
+ demo.launch(
623
+ share=False, # To create a public link, set `share=True`
624
+ server_name="0.0.0.0",
625
+ server_port=7860
626
+ )
assets/screenshot_classify.png ADDED
citescan.yaml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ files:
2
+ bib: "test.bib"
3
+ output_dir: "test"
4
+
5
+ bibliography:
6
+ check_metadata: true
7
+ check_duplicates: true
8
+ check_preprint_ratio: true
9
+ preprint_warning_threshold: 0.50
10
+
11
+ workflow:
12
+ - name: arxiv_id
13
+ enabled: true
14
+ description: "Lookup by arXiv ID"
15
+ - name: crossref_doi
16
+ enabled: true
17
+ description: "Lookup by DOI"
18
+ - name: semantic_scholar
19
+ enabled: true
20
+ description: "Semantic Scholar API"
21
+ - name: dblp
22
+ enabled: true
23
+ description: "DBLP"
24
+ - name: openalex
25
+ enabled: true
26
+ description: "OpenAlex API"
27
+ - name: arxiv_title
28
+ enabled: true
29
+ description: "Search arXiv by title"
30
+ - name: crossref_title
31
+ enabled: true
32
+ description: "Search CrossRef by title"
33
+ - name: google_scholar
34
+ enabled: false
35
+ description: "Google Scholar (may be rate-limited)"
36
+
37
+ output:
38
+ quiet: false
39
+ minimal_verified: false
config.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ files:
2
+ bib: "paper.bib"
3
+ output_dir: "citescan_output"
4
+
5
+ bibliography:
6
+ check_metadata: true
7
+ check_usage: true
8
+ check_duplicates: true
9
+ check_preprint_ratio: true
10
+ preprint_warning_threshold: 0.50
11
+ check_relevance: false
12
+
13
+ submission:
14
+ caption: true
15
+ reference: true
16
+ formatting: true
17
+ equation: true
18
+ ai_artifacts: true
19
+ sentence: true
20
+ consistency: true
21
+ acronym: true
22
+ number: true
23
+ citation_quality: true
24
+ anonymization: true
25
+
26
+ llm:
27
+ backend: "deepseeek"
28
+ model: "deepseek-chat"
29
+ api_key: "sk-d7c87a7386d94879a80282cee7bd3f45"
30
+
31
+ output:
32
+ quiet: false
33
+ minimal_verified: false
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.5.0
2
+ bibtexparser>=1.4.0
3
+ requests>=2.31.0
4
+ beautifulsoup4>=4.12.0
5
+ rich>=13.7.0
6
+ Unidecode>=1.3.0
7
+ lxml>=5.0.0
8
+ PyYAML>=6.0
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Bibliography Checker Package"""
src/analyzers/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Analyzers package (bib-only)."""
2
+ from .metadata_comparator import MetadataComparator
3
+ from .duplicate_detector import DuplicateDetector
4
+
5
+ __all__ = ["MetadataComparator", "DuplicateDetector"]
src/analyzers/duplicate_detector.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Duplicate entry detector for bibliography files.
3
+ Uses fuzzy matching to find potential duplicates.
4
+ """
5
+ from dataclasses import dataclass
6
+ from typing import List, Tuple
7
+
8
+ from ..parsers.bib_parser import BibEntry
9
+ from ..utils.normalizer import TextNormalizer
10
+
11
+
12
+ @dataclass
13
+ class DuplicateGroup:
14
+ """A group of potentially duplicate entries."""
15
+ entries: List[BibEntry]
16
+ similarity_score: float
17
+ reason: str
18
+
19
+ @property
20
+ def entry_keys(self) -> List[str]:
21
+ return [e.key for e in self.entries]
22
+
23
+
24
+ class DuplicateDetector:
25
+ """Detects duplicate bibliography entries using fuzzy matching."""
26
+
27
+ # Thresholds for duplicate detection
28
+ TITLE_SIMILARITY_THRESHOLD = 0.85
29
+ COMBINED_SIMILARITY_THRESHOLD = 0.80
30
+
31
+ def __init__(self):
32
+ self.normalizer = TextNormalizer
33
+
34
+ def find_duplicates(self, entries: List[BibEntry]) -> List[DuplicateGroup]:
35
+ """
36
+ Find all duplicate groups in the bibliography.
37
+
38
+ Returns:
39
+ List of DuplicateGroup objects, each containing 2+ similar entries.
40
+ """
41
+ duplicates = []
42
+ processed = set()
43
+
44
+ for i, entry1 in enumerate(entries):
45
+ if entry1.key in processed:
46
+ continue
47
+
48
+ # Find all entries similar to this one
49
+ similar_entries = [entry1]
50
+
51
+ for j, entry2 in enumerate(entries[i+1:], start=i+1):
52
+ if entry2.key in processed:
53
+ continue
54
+
55
+ similarity, reason = self._calculate_similarity(entry1, entry2)
56
+
57
+ if similarity >= self.COMBINED_SIMILARITY_THRESHOLD:
58
+ similar_entries.append(entry2)
59
+ processed.add(entry2.key)
60
+
61
+ # If we found duplicates, create a group
62
+ if len(similar_entries) > 1:
63
+ processed.add(entry1.key)
64
+
65
+ # Calculate average similarity for the group
66
+ avg_similarity = self._calculate_group_similarity(similar_entries)
67
+ reason = self._generate_reason(similar_entries)
68
+
69
+ duplicates.append(DuplicateGroup(
70
+ entries=similar_entries,
71
+ similarity_score=avg_similarity,
72
+ reason=reason
73
+ ))
74
+
75
+ # Sort by similarity score (highest first)
76
+ duplicates.sort(key=lambda g: g.similarity_score, reverse=True)
77
+
78
+ return duplicates
79
+
80
+ def _calculate_similarity(self, entry1: BibEntry, entry2: BibEntry) -> Tuple[float, str]:
81
+ """
82
+ Calculate similarity between two entries.
83
+
84
+ Returns:
85
+ (similarity_score, reason_string)
86
+ """
87
+ # Normalize titles
88
+ title1 = self.normalizer.normalize_for_comparison(entry1.title)
89
+ title2 = self.normalizer.normalize_for_comparison(entry2.title)
90
+
91
+ # Calculate title similarity
92
+ title_sim = self.normalizer.similarity_ratio(title1, title2)
93
+
94
+ # If titles are very similar, likely a duplicate
95
+ if title_sim >= self.TITLE_SIMILARITY_THRESHOLD:
96
+ return title_sim, "Very similar titles"
97
+
98
+ # Check author similarity
99
+ author_sim = self._calculate_author_similarity(entry1, entry2)
100
+
101
+ # Combined score: weighted average
102
+ # Title is more important (70%) than authors (30%)
103
+ combined_sim = 0.7 * title_sim + 0.3 * author_sim
104
+
105
+ if combined_sim >= self.COMBINED_SIMILARITY_THRESHOLD:
106
+ return combined_sim, f"Similar title ({title_sim:.0%}) and authors ({author_sim:.0%})"
107
+
108
+ return combined_sim, ""
109
+
110
+ def _calculate_author_similarity(self, entry1: BibEntry, entry2: BibEntry) -> float:
111
+ """Calculate similarity between author lists."""
112
+ # Parse author strings
113
+ authors1 = self._parse_authors(entry1.author)
114
+ authors2 = self._parse_authors(entry2.author)
115
+
116
+ if not authors1 or not authors2:
117
+ return 0.0
118
+
119
+ # Normalize author names
120
+ norm_authors1 = [self.normalizer.normalize_for_comparison(a) for a in authors1]
121
+ norm_authors2 = [self.normalizer.normalize_for_comparison(a) for a in authors2]
122
+
123
+ # Count matching authors
124
+ matches = 0
125
+ for a1 in norm_authors1:
126
+ for a2 in norm_authors2:
127
+ if self._authors_match(a1, a2):
128
+ matches += 1
129
+ break
130
+
131
+ # Calculate Jaccard similarity
132
+ total_unique = len(set(norm_authors1) | set(norm_authors2))
133
+ if total_unique == 0:
134
+ return 0.0
135
+
136
+ return matches / total_unique
137
+
138
+ def _parse_authors(self, author_string: str) -> List[str]:
139
+ """Parse author string into list of names."""
140
+ if not author_string:
141
+ return []
142
+
143
+ # Split by 'and'
144
+ authors = author_string.split(' and ')
145
+
146
+ # Clean up each author
147
+ cleaned = []
148
+ for author in authors:
149
+ # Remove extra whitespace
150
+ author = ' '.join(author.split())
151
+ if author:
152
+ cleaned.append(author)
153
+
154
+ return cleaned
155
+
156
+ def _authors_match(self, name1: str, name2: str) -> bool:
157
+ """Check if two author names match (handles initials)."""
158
+ # Simple exact match after normalization
159
+ if name1 == name2:
160
+ return True
161
+
162
+ # Check if one is a substring of the other (handles initials)
163
+ if name1 in name2 or name2 in name1:
164
+ return True
165
+
166
+ # Calculate string similarity
167
+ sim = self.normalizer.similarity_ratio(name1, name2)
168
+ return sim >= 0.8
169
+
170
+ def _calculate_group_similarity(self, entries: List[BibEntry]) -> float:
171
+ """Calculate average similarity within a group."""
172
+ if len(entries) < 2:
173
+ return 1.0
174
+
175
+ total_sim = 0.0
176
+ count = 0
177
+
178
+ for i, entry1 in enumerate(entries):
179
+ for entry2 in entries[i+1:]:
180
+ sim, _ = self._calculate_similarity(entry1, entry2)
181
+ total_sim += sim
182
+ count += 1
183
+
184
+ return total_sim / count if count > 0 else 0.0
185
+
186
+ def _generate_reason(self, entries: List[BibEntry]) -> str:
187
+ """Generate a human-readable reason for the duplicate group."""
188
+ # Check if all titles are very similar
189
+ titles = [self.normalizer.normalize_for_comparison(e.title) for e in entries]
190
+
191
+ # Calculate pairwise title similarities
192
+ title_sims = []
193
+ for i, t1 in enumerate(titles):
194
+ for t2 in titles[i+1:]:
195
+ title_sims.append(self.normalizer.similarity_ratio(t1, t2))
196
+
197
+ avg_title_sim = sum(title_sims) / len(title_sims) if title_sims else 0.0
198
+
199
+ if avg_title_sim >= 0.95:
200
+ return "Nearly identical titles"
201
+ elif avg_title_sim >= 0.85:
202
+ return "Very similar titles"
203
+ else:
204
+ return "Similar titles and authors"
src/analyzers/metadata_comparator.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Metadata comparison between bib entries and fetched metadata.
3
+ """
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ from ..parsers.bib_parser import BibEntry
8
+ from ..fetchers.arxiv_fetcher import ArxivMetadata
9
+ from ..fetchers.scholar_fetcher import ScholarResult
10
+ from ..fetchers.crossref_fetcher import CrossRefResult
11
+ from ..fetchers.semantic_scholar_fetcher import SemanticScholarResult
12
+ from ..fetchers.openalex_fetcher import OpenAlexResult
13
+ from ..fetchers.dblp_fetcher import DBLPResult
14
+ from ..utils.normalizer import TextNormalizer
15
+ from ..utils.fetched_bibtex import build_fetched_bibtex
16
+
17
+
18
+ @dataclass
19
+ class ComparisonResult:
20
+ """Result of comparing bib entry with fetched metadata."""
21
+ entry_key: str
22
+
23
+ # Title comparison
24
+ title_match: bool
25
+ title_similarity: float
26
+ bib_title: str
27
+ fetched_title: str
28
+
29
+ # Author comparison
30
+ author_match: bool
31
+ author_similarity: float
32
+ bib_authors: list[str]
33
+ fetched_authors: list[str]
34
+
35
+ # Year comparison
36
+ year_match: bool
37
+ bib_year: str
38
+ fetched_year: str
39
+
40
+ # Overall assessment
41
+ is_match: bool
42
+ confidence: float
43
+ issues: list[str]
44
+ source: str # 'arxiv', 'crossref', 'scholar', 'semantic_scholar', 'openalex', 'dblp', or 'unable'
45
+ fetched_bibtex: Optional[str] = None # BibTeX string of the ground-truth entry when available
46
+ fetched_url: Optional[str] = None
47
+ fetched_doi: Optional[str] = None
48
+
49
+ @property
50
+ def has_issues(self) -> bool:
51
+ return len(self.issues) > 0
52
+
53
+
54
+ class MetadataComparator:
55
+ """Compares bibliography entries with fetched metadata."""
56
+
57
+ # Thresholds for matching
58
+ TITLE_THRESHOLD = 0.99
59
+ AUTHOR_THRESHOLD = 0.9
60
+
61
+ def __init__(self):
62
+ self.normalizer = TextNormalizer
63
+
64
+ def compare_with_arxiv(self, bib_entry: BibEntry, arxiv_meta: ArxivMetadata) -> ComparisonResult:
65
+ """Compare bib entry with arXiv metadata."""
66
+ issues = []
67
+
68
+ # Compare titles
69
+ bib_title_norm = self.normalizer.normalize_for_comparison(bib_entry.title)
70
+ arxiv_title_norm = self.normalizer.normalize_for_comparison(arxiv_meta.title)
71
+
72
+ title_similarity = self.normalizer.similarity_ratio(bib_title_norm, arxiv_title_norm)
73
+ if len(bib_title_norm) < 100:
74
+ lev_sim = self.normalizer.levenshtein_similarity(bib_title_norm, arxiv_title_norm)
75
+ title_similarity = min(title_similarity, lev_sim)
76
+
77
+ title_match = title_similarity >= self.TITLE_THRESHOLD
78
+
79
+ if not title_match:
80
+ issues.append(f"Title mismatch (similarity: {title_similarity:.2%})")
81
+
82
+ # Compare authors
83
+ bib_authors = self.normalizer.normalize_author_list(bib_entry.author)
84
+ arxiv_authors = [self.normalizer.normalize_author_name(a) for a in arxiv_meta.authors]
85
+
86
+ author_similarity = self._compare_author_lists(bib_authors, arxiv_authors)
87
+ author_match = author_similarity >= self.AUTHOR_THRESHOLD
88
+
89
+ if not author_match:
90
+ issues.append(f"Author mismatch (similarity: {author_similarity:.2%})")
91
+
92
+ # Compare years
93
+ bib_year = bib_entry.year.strip()
94
+ arxiv_year = arxiv_meta.year
95
+ year_match = bib_year == arxiv_year
96
+
97
+ if not year_match and bib_year and arxiv_year:
98
+ issues.append(f"Year mismatch: bib={bib_year}, arxiv={arxiv_year}")
99
+
100
+ # Overall assessment
101
+ is_match = title_match and author_match
102
+ confidence = (title_similarity * 0.5 + author_similarity * 0.3 + (1.0 if year_match else 0.5) * 0.2)
103
+
104
+ return ComparisonResult(
105
+ entry_key=bib_entry.key,
106
+ title_match=title_match,
107
+ title_similarity=title_similarity,
108
+ bib_title=bib_entry.title,
109
+ fetched_title=arxiv_meta.title,
110
+ author_match=author_match,
111
+ author_similarity=author_similarity,
112
+ bib_authors=bib_authors,
113
+ fetched_authors=arxiv_authors,
114
+ year_match=year_match,
115
+ bib_year=bib_year,
116
+ fetched_year=arxiv_year,
117
+ is_match=is_match,
118
+ confidence=confidence,
119
+ issues=issues,
120
+ source="arxiv",
121
+ fetched_bibtex=build_fetched_bibtex("arxiv", arxiv_meta),
122
+ fetched_url=getattr(arxiv_meta, "abs_url", None) or "",
123
+ fetched_doi=getattr(arxiv_meta, "doi", None) or "",
124
+ )
125
+
126
+ def compare_with_scholar(self, bib_entry: BibEntry, scholar_result: ScholarResult) -> ComparisonResult:
127
+ """Compare bib entry with Scholar search result."""
128
+ issues = []
129
+
130
+ # Compare titles
131
+ bib_title_norm = self.normalizer.normalize_for_comparison(bib_entry.title)
132
+ scholar_title_norm = self.normalizer.normalize_for_comparison(scholar_result.title)
133
+
134
+ title_similarity = self.normalizer.similarity_ratio(bib_title_norm, scholar_title_norm)
135
+ if len(bib_title_norm) < 100:
136
+ lev_sim = self.normalizer.levenshtein_similarity(bib_title_norm, scholar_title_norm)
137
+ title_similarity = min(title_similarity, lev_sim)
138
+
139
+ title_match = title_similarity >= self.TITLE_THRESHOLD
140
+
141
+ if not title_match:
142
+ issues.append(f"Title mismatch (similarity: {title_similarity:.2%})")
143
+
144
+ # Compare authors (Scholar format is less structured)
145
+ bib_authors = self.normalizer.normalize_author_list(bib_entry.author)
146
+ # Scholar authors are comma-separated
147
+ scholar_authors_raw = scholar_result.authors.split(',')
148
+ scholar_authors = [self.normalizer.normalize_author_name(a.strip()) for a in scholar_authors_raw]
149
+
150
+ author_similarity = self._compare_author_lists(bib_authors, scholar_authors)
151
+ author_match = author_similarity >= self.AUTHOR_THRESHOLD
152
+
153
+ if not author_match:
154
+ issues.append(f"Author mismatch (similarity: {author_similarity:.2%})")
155
+
156
+ # Compare years
157
+ bib_year = bib_entry.year.strip()
158
+ scholar_year = scholar_result.year
159
+ year_match = bib_year == scholar_year
160
+
161
+ if not year_match and bib_year and scholar_year:
162
+ issues.append(f"Year mismatch: bib={bib_year}, scholar={scholar_year}")
163
+
164
+ # Overall assessment
165
+ is_match = title_match and author_match
166
+ confidence = (title_similarity * 0.5 + author_similarity * 0.3 + (1.0 if year_match else 0.5) * 0.2)
167
+
168
+ return ComparisonResult(
169
+ entry_key=bib_entry.key,
170
+ title_match=title_match,
171
+ title_similarity=title_similarity,
172
+ bib_title=bib_entry.title,
173
+ fetched_title=scholar_result.title,
174
+ author_match=author_match,
175
+ author_similarity=author_similarity,
176
+ bib_authors=bib_authors,
177
+ fetched_authors=scholar_authors,
178
+ year_match=year_match,
179
+ bib_year=bib_year,
180
+ fetched_year=scholar_year,
181
+ is_match=is_match,
182
+ confidence=confidence,
183
+ issues=issues,
184
+ source="scholar",
185
+ fetched_bibtex=build_fetched_bibtex("scholar", scholar_result),
186
+ fetched_url=getattr(scholar_result, "url", None) or "",
187
+ fetched_doi=None,
188
+ )
189
+
190
+ def compare_with_crossref(self, bib_entry: BibEntry, crossref_result: CrossRefResult) -> ComparisonResult:
191
+ """Compare bib entry with CrossRef search result."""
192
+ issues = []
193
+
194
+ # Compare titles
195
+ bib_title_norm = self.normalizer.normalize_for_comparison(bib_entry.title)
196
+ crossref_title_norm = self.normalizer.normalize_for_comparison(crossref_result.title)
197
+
198
+ title_similarity = self.normalizer.similarity_ratio(bib_title_norm, crossref_title_norm)
199
+ if len(bib_title_norm) < 100:
200
+ lev_sim = self.normalizer.levenshtein_similarity(bib_title_norm, crossref_title_norm)
201
+ title_similarity = min(title_similarity, lev_sim)
202
+
203
+ title_match = title_similarity >= self.TITLE_THRESHOLD
204
+
205
+ if not title_match:
206
+ issues.append(f"Title mismatch (similarity: {title_similarity:.2%})")
207
+
208
+ # Compare authors
209
+ bib_authors = self.normalizer.normalize_author_list(bib_entry.author)
210
+ crossref_authors = [self.normalizer.normalize_author_name(a) for a in crossref_result.authors]
211
+
212
+ author_similarity = self._compare_author_lists(bib_authors, crossref_authors)
213
+ author_match = author_similarity >= self.AUTHOR_THRESHOLD
214
+
215
+ if not author_match:
216
+ issues.append(f"Author mismatch (similarity: {author_similarity:.2%})")
217
+
218
+ # Compare years
219
+ bib_year = bib_entry.year.strip()
220
+ crossref_year = crossref_result.year
221
+ year_match = bib_year == crossref_year
222
+
223
+ if not year_match and bib_year and crossref_year:
224
+ issues.append(f"Year mismatch: bib={bib_year}, crossref={crossref_year}")
225
+
226
+ # Overall assessment
227
+ is_match = title_match and author_match
228
+ confidence = (title_similarity * 0.5 + author_similarity * 0.3 + (1.0 if year_match else 0.5) * 0.2)
229
+
230
+ return ComparisonResult(
231
+ entry_key=bib_entry.key,
232
+ title_match=title_match,
233
+ title_similarity=title_similarity,
234
+ bib_title=bib_entry.title,
235
+ fetched_title=crossref_result.title,
236
+ author_match=author_match,
237
+ author_similarity=author_similarity,
238
+ bib_authors=bib_authors,
239
+ fetched_authors=crossref_authors,
240
+ year_match=year_match,
241
+ bib_year=bib_year,
242
+ fetched_year=crossref_year,
243
+ is_match=is_match,
244
+ confidence=confidence,
245
+ issues=issues,
246
+ source="crossref",
247
+ fetched_bibtex=build_fetched_bibtex("crossref", crossref_result),
248
+ fetched_url=getattr(crossref_result, "url", None) or "",
249
+ fetched_doi=getattr(crossref_result, "doi", None) or "",
250
+ )
251
+
252
+ def create_unable_result(self, bib_entry: BibEntry, reason: str = "Unable to fetch metadata") -> ComparisonResult:
253
+ """Create result when metadata couldn't be fetched."""
254
+ return ComparisonResult(
255
+ entry_key=bib_entry.key,
256
+ title_match=False,
257
+ title_similarity=0.0,
258
+ bib_title=bib_entry.title,
259
+ fetched_title="",
260
+ author_match=False,
261
+ author_similarity=0.0,
262
+ bib_authors=self.normalizer.normalize_author_list(bib_entry.author),
263
+ fetched_authors=[],
264
+ year_match=False,
265
+ bib_year=bib_entry.year,
266
+ fetched_year="",
267
+ is_match=False,
268
+ confidence=0.0,
269
+ issues=[reason],
270
+ source="unable"
271
+ )
272
+
273
+ def _compare_author_lists(self, list1: list[str], list2: list[str]) -> float:
274
+ """Compare two author lists using Jaccard similarity (order-invariant, symmetric)."""
275
+ if not list1 and not list2:
276
+ return 1.0
277
+ if not list1 or not list2:
278
+ return 0.0
279
+
280
+ # Max matching: each author in list1 matches at most one in list2, and vice versa
281
+ used = set()
282
+ match_count = 0
283
+ for author1 in list1:
284
+ for j, author2 in enumerate(list2):
285
+ if j not in used and self._names_match(author1, author2):
286
+ used.add(j)
287
+ match_count += 1
288
+ break
289
+
290
+ # Jaccard: |A ∩ B| / |A ∪ B| = match_count / (n + m - match_count)
291
+ union_size = len(list1) + len(list2) - match_count
292
+ if union_size <= 0:
293
+ return 1.0
294
+ return match_count / union_size
295
+
296
+ def _names_match(self, name1: str, name2: str) -> bool:
297
+ """Check if two names match (handles abbreviated names)."""
298
+ words1 = name1.split()
299
+ words2 = name2.split()
300
+
301
+ if not words1 or not words2:
302
+ return False
303
+
304
+ # Check if last names match
305
+ if words1[-1] != words2[-1]:
306
+ # Try first word as last name too
307
+ if words1[0] != words2[-1] and words1[-1] != words2[0]:
308
+ return False
309
+
310
+ return True
311
+
312
+ def compare_with_semantic_scholar(self, bib_entry: BibEntry, ss_result: SemanticScholarResult) -> ComparisonResult:
313
+ """Compare bib entry with Semantic Scholar result."""
314
+ issues = []
315
+
316
+ # Compare titles
317
+ bib_title_norm = self.normalizer.normalize_for_comparison(bib_entry.title)
318
+ ss_title_norm = self.normalizer.normalize_for_comparison(ss_result.title)
319
+
320
+ title_similarity = self.normalizer.similarity_ratio(bib_title_norm, ss_title_norm)
321
+ if len(bib_title_norm) < 100:
322
+ lev_sim = self.normalizer.levenshtein_similarity(bib_title_norm, ss_title_norm)
323
+ title_similarity = min(title_similarity, lev_sim)
324
+
325
+ title_match = title_similarity >= self.TITLE_THRESHOLD
326
+
327
+ if not title_match:
328
+ issues.append(f"Title mismatch (similarity: {title_similarity:.2%})")
329
+
330
+ # Compare authors
331
+ bib_authors = self.normalizer.normalize_author_list(bib_entry.author)
332
+ ss_authors = [self.normalizer.normalize_author_name(a) for a in ss_result.authors]
333
+
334
+ author_similarity = self._compare_author_lists(bib_authors, ss_authors)
335
+ author_match = author_similarity >= self.AUTHOR_THRESHOLD
336
+
337
+ if not author_match:
338
+ issues.append(f"Author mismatch (similarity: {author_similarity:.2%})")
339
+
340
+ # Compare years
341
+ bib_year = bib_entry.year.strip()
342
+ ss_year = ss_result.year
343
+ year_match = bib_year == ss_year
344
+
345
+ if not year_match and bib_year and ss_year:
346
+ issues.append(f"Year mismatch: bib={bib_year}, semantic_scholar={ss_year}")
347
+
348
+ # Overall assessment
349
+ is_match = title_match and author_match
350
+ confidence = (title_similarity * 0.5 + author_similarity * 0.3 + (1.0 if year_match else 0.5) * 0.2)
351
+
352
+ return ComparisonResult(
353
+ entry_key=bib_entry.key,
354
+ title_match=title_match,
355
+ title_similarity=title_similarity,
356
+ bib_title=bib_entry.title,
357
+ fetched_title=ss_result.title,
358
+ author_match=author_match,
359
+ author_similarity=author_similarity,
360
+ bib_authors=bib_authors,
361
+ fetched_authors=ss_authors,
362
+ year_match=year_match,
363
+ bib_year=bib_year,
364
+ fetched_year=ss_year,
365
+ is_match=is_match,
366
+ confidence=confidence,
367
+ issues=issues,
368
+ source="semantic_scholar",
369
+ fetched_bibtex=build_fetched_bibtex("semantic_scholar", ss_result),
370
+ fetched_url=getattr(ss_result, "url", None) or "",
371
+ fetched_doi=None,
372
+ )
373
+
374
+ def compare_with_openalex(self, bib_entry: BibEntry, oa_result: OpenAlexResult) -> ComparisonResult:
375
+ """Compare bib entry with OpenAlex result."""
376
+ issues = []
377
+
378
+ # Compare titles
379
+ bib_title_norm = self.normalizer.normalize_for_comparison(bib_entry.title)
380
+ oa_title_norm = self.normalizer.normalize_for_comparison(oa_result.title)
381
+
382
+ title_similarity = self.normalizer.similarity_ratio(bib_title_norm, oa_title_norm)
383
+ if len(bib_title_norm) < 100:
384
+ lev_sim = self.normalizer.levenshtein_similarity(bib_title_norm, oa_title_norm)
385
+ title_similarity = min(title_similarity, lev_sim)
386
+
387
+ title_match = title_similarity >= self.TITLE_THRESHOLD
388
+
389
+ if not title_match:
390
+ issues.append(f"Title mismatch (similarity: {title_similarity:.2%})")
391
+
392
+ # Compare authors
393
+ bib_authors = self.normalizer.normalize_author_list(bib_entry.author)
394
+ oa_authors = [self.normalizer.normalize_author_name(a) for a in oa_result.authors]
395
+
396
+ author_similarity = self._compare_author_lists(bib_authors, oa_authors)
397
+ author_match = author_similarity >= self.AUTHOR_THRESHOLD
398
+
399
+ if not author_match:
400
+ issues.append(f"Author mismatch (similarity: {author_similarity:.2%})")
401
+
402
+ # Compare years
403
+ bib_year = bib_entry.year.strip()
404
+ oa_year = oa_result.year
405
+ year_match = bib_year == oa_year
406
+
407
+ if not year_match and bib_year and oa_year:
408
+ issues.append(f"Year mismatch: bib={bib_year}, openalex={oa_year}")
409
+
410
+ # Overall assessment
411
+ is_match = title_match and author_match
412
+ confidence = (title_similarity * 0.5 + author_similarity * 0.3 + (1.0 if year_match else 0.5) * 0.2)
413
+
414
+ return ComparisonResult(
415
+ entry_key=bib_entry.key,
416
+ title_match=title_match,
417
+ title_similarity=title_similarity,
418
+ bib_title=bib_entry.title,
419
+ fetched_title=oa_result.title,
420
+ author_match=author_match,
421
+ author_similarity=author_similarity,
422
+ bib_authors=bib_authors,
423
+ fetched_authors=oa_authors,
424
+ year_match=year_match,
425
+ bib_year=bib_year,
426
+ fetched_year=oa_year,
427
+ is_match=is_match,
428
+ confidence=confidence,
429
+ issues=issues,
430
+ source="openalex",
431
+ fetched_bibtex=build_fetched_bibtex("openalex", oa_result),
432
+ fetched_url=getattr(oa_result, "url", None) or "",
433
+ fetched_doi=getattr(oa_result, "doi", None) or "",
434
+ )
435
+
436
+ def compare_with_dblp(self, bib_entry: BibEntry, dblp_result: DBLPResult) -> ComparisonResult:
437
+ """Compare bib entry with DBLP result."""
438
+ issues = []
439
+
440
+ # Compare titles
441
+ bib_title_norm = self.normalizer.normalize_for_comparison(bib_entry.title)
442
+ dblp_title_norm = self.normalizer.normalize_for_comparison(dblp_result.title)
443
+
444
+ title_similarity = self.normalizer.similarity_ratio(bib_title_norm, dblp_title_norm)
445
+ if len(bib_title_norm) < 100:
446
+ lev_sim = self.normalizer.levenshtein_similarity(bib_title_norm, dblp_title_norm)
447
+ title_similarity = min(title_similarity, lev_sim)
448
+
449
+ title_match = title_similarity >= self.TITLE_THRESHOLD
450
+
451
+ if not title_match:
452
+ issues.append(f"Title mismatch (similarity: {title_similarity:.2%})")
453
+
454
+ # Compare authors
455
+ bib_authors = self.normalizer.normalize_author_list(bib_entry.author)
456
+ dblp_authors = [self.normalizer.normalize_author_name(a) for a in dblp_result.authors]
457
+
458
+ author_similarity = self._compare_author_lists(bib_authors, dblp_authors)
459
+ author_match = author_similarity >= self.AUTHOR_THRESHOLD
460
+
461
+ if not author_match:
462
+ issues.append(f"Author mismatch (similarity: {author_similarity:.2%})")
463
+
464
+ # Compare years
465
+ bib_year = bib_entry.year.strip()
466
+ dblp_year = dblp_result.year
467
+ year_match = bib_year == dblp_year
468
+
469
+ if not year_match and bib_year and dblp_year:
470
+ issues.append(f"Year mismatch: bib={bib_year}, dblp={dblp_year}")
471
+
472
+ # Overall assessment
473
+ is_match = title_match and author_match
474
+ confidence = (title_similarity * 0.5 + author_similarity * 0.3 + (1.0 if year_match else 0.5) * 0.2)
475
+
476
+ return ComparisonResult(
477
+ entry_key=bib_entry.key,
478
+ title_match=title_match,
479
+ title_similarity=title_similarity,
480
+ bib_title=bib_entry.title,
481
+ fetched_title=dblp_result.title,
482
+ author_match=author_match,
483
+ author_similarity=author_similarity,
484
+ bib_authors=bib_authors,
485
+ fetched_authors=dblp_authors,
486
+ year_match=year_match,
487
+ bib_year=bib_year,
488
+ fetched_year=dblp_year,
489
+ is_match=is_match,
490
+ confidence=confidence,
491
+ issues=issues,
492
+ source="dblp",
493
+ fetched_bibtex=build_fetched_bibtex("dblp", dblp_result),
494
+ fetched_url=getattr(dblp_result, "url", None) or "",
495
+ fetched_doi=getattr(dblp_result, "doi", None) or "",
496
+ )
497
+
498
+ # TODO: Use API to compare metadata
499
+ if __name__ == "__main__":
500
+ comparator = MetadataComparator()
501
+ result = comparator.compare_with_arxiv(BibEntry(key="test", title="Zero Bubble Pipeline Parallelism", author="Penghui Qi, Xinyi Wan, Guangxing Huang, Min Lin"), ArxivMetadata(title="Zero Bubble Pipeline Parallelism", authors=["Penghui Qi", "Xinyi Wan", "Guangxing Huang", "Min Lin"], year="2023"))
502
+ print('result: \n', result)
src/config/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Configuration module for citescan."""
2
+ from .workflow import WorkflowConfig, WorkflowStep, DEFAULT_WORKFLOW, get_default_workflow
3
+
4
+ __all__ = ['WorkflowConfig', 'WorkflowStep', 'DEFAULT_WORKFLOW', 'get_default_workflow']
src/config/workflow.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Workflow configuration for reference checking.
3
+
4
+ Allows users to customize the order and enable/disable individual fetchers
5
+ in the reference verification workflow.
6
+ """
7
+ import json
8
+ from dataclasses import dataclass, field, asdict
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+
13
+ @dataclass
14
+ class WorkflowStep:
15
+ """A single step in the reference checking workflow."""
16
+ name: str
17
+ display_name: str
18
+ description: str
19
+ enabled: bool = True
20
+ priority: int = 0
21
+
22
+ # Step type: 'by_id', 'by_doi', 'by_title'
23
+ search_type: str = 'by_title'
24
+
25
+ def to_dict(self) -> dict:
26
+ return asdict(self)
27
+
28
+ @classmethod
29
+ def from_dict(cls, data: dict) -> 'WorkflowStep':
30
+ return cls(**data)
31
+
32
+
33
+ @dataclass
34
+ class WorkflowConfig:
35
+ """Configuration for the reference checking workflow."""
36
+ steps: List[WorkflowStep] = field(default_factory=list)
37
+ name: str = "default"
38
+ description: str = "Default workflow configuration"
39
+
40
+ def get_enabled_steps(self) -> List[WorkflowStep]:
41
+ """Get only enabled steps, sorted by priority."""
42
+ return sorted(
43
+ [s for s in self.steps if s.enabled],
44
+ key=lambda x: x.priority
45
+ )
46
+
47
+ def move_step_up(self, index: int) -> bool:
48
+ """Move a step up in priority (swap with previous)."""
49
+ if index <= 0 or index >= len(self.steps):
50
+ return False
51
+ self.steps[index], self.steps[index - 1] = self.steps[index - 1], self.steps[index]
52
+ self._update_priorities()
53
+ return True
54
+
55
+ def move_step_down(self, index: int) -> bool:
56
+ """Move a step down in priority (swap with next)."""
57
+ if index < 0 or index >= len(self.steps) - 1:
58
+ return False
59
+ self.steps[index], self.steps[index + 1] = self.steps[index + 1], self.steps[index]
60
+ self._update_priorities()
61
+ return True
62
+
63
+ def toggle_step(self, index: int) -> bool:
64
+ """Toggle enabled status of a step."""
65
+ if 0 <= index < len(self.steps):
66
+ self.steps[index].enabled = not self.steps[index].enabled
67
+ return True
68
+ return False
69
+
70
+ def _update_priorities(self):
71
+ """Update priority values based on current order."""
72
+ for i, step in enumerate(self.steps):
73
+ step.priority = i
74
+
75
+ def to_dict(self) -> dict:
76
+ return {
77
+ 'name': self.name,
78
+ 'description': self.description,
79
+ 'steps': [s.to_dict() for s in self.steps]
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> 'WorkflowConfig':
84
+ steps = [WorkflowStep.from_dict(s) for s in data.get('steps', [])]
85
+ return cls(
86
+ steps=steps,
87
+ name=data.get('name', 'custom'),
88
+ description=data.get('description', '')
89
+ )
90
+
91
+ def save(self, filepath: str):
92
+ """Save workflow configuration to JSON file."""
93
+ path = Path(filepath)
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ with open(path, 'w', encoding='utf-8') as f:
96
+ json.dump(self.to_dict(), f, indent=2)
97
+
98
+ @classmethod
99
+ def load(cls, filepath: str) -> 'WorkflowConfig':
100
+ """Load workflow configuration from JSON file."""
101
+ with open(filepath, 'r', encoding='utf-8') as f:
102
+ data = json.load(f)
103
+ return cls.from_dict(data)
104
+
105
+
106
+ # Default workflow matching current implementation order
107
+ DEFAULT_WORKFLOW = WorkflowConfig(
108
+ name="default",
109
+ description="Default reference checking workflow prioritizing reliable APIs",
110
+ steps=[
111
+ WorkflowStep(
112
+ name="arxiv_id",
113
+ display_name="arXiv by ID",
114
+ description="Look up paper by arXiv ID (highest priority for arXiv papers)",
115
+ priority=0,
116
+ search_type="by_id"
117
+ ),
118
+ WorkflowStep(
119
+ name="crossref_doi",
120
+ display_name="CrossRef by DOI",
121
+ description="Look up paper by DOI (authoritative for DOIs)",
122
+ priority=1,
123
+ search_type="by_doi"
124
+ ),
125
+ WorkflowStep(
126
+ name="semantic_scholar",
127
+ display_name="Semantic Scholar",
128
+ description="Official API with high quality metadata",
129
+ priority=2,
130
+ search_type="by_title"
131
+ ),
132
+ WorkflowStep(
133
+ name="dblp",
134
+ display_name="DBLP",
135
+ description="Official API, especially good for CS publications",
136
+ priority=3,
137
+ search_type="by_title"
138
+ ),
139
+ WorkflowStep(
140
+ name="openalex",
141
+ display_name="OpenAlex",
142
+ description="Official API with broad coverage",
143
+ priority=4,
144
+ search_type="by_title"
145
+ ),
146
+ WorkflowStep(
147
+ name="arxiv_title",
148
+ display_name="arXiv by Title",
149
+ description="Search arXiv by title (fallback for non-ID lookups)",
150
+ priority=5,
151
+ search_type="by_title"
152
+ ),
153
+ WorkflowStep(
154
+ name="crossref_title",
155
+ display_name="CrossRef by Title",
156
+ description="Search CrossRef by title",
157
+ priority=6,
158
+ search_type="by_title"
159
+ ),
160
+ WorkflowStep(
161
+ name="google_scholar",
162
+ display_name="Google Scholar",
163
+ description="Web scraping fallback (may be rate-limited or blocked)",
164
+ priority=7,
165
+ search_type="by_title",
166
+ enabled=True # Still enabled but lowest priority
167
+ ),
168
+ ]
169
+ )
170
+
171
+
172
+ def get_default_workflow() -> WorkflowConfig:
173
+ """Get a fresh copy of the default workflow."""
174
+ return WorkflowConfig.from_dict(DEFAULT_WORKFLOW.to_dict())
src/config/yaml_config.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ YAML configuration loader for citescan (bib-only).
3
+ """
4
+ import yaml
5
+ from pathlib import Path
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, List
8
+
9
+
10
+ @dataclass
11
+ class FilesConfig:
12
+ """File path configuration (bib-only)."""
13
+ bib: str = ""
14
+ input_dir: str = ""
15
+ output_dir: str = "citescan_output"
16
+
17
+
18
+ @dataclass
19
+ class BibliographyConfig:
20
+ """Bibliography check configuration."""
21
+ check_metadata: bool = True
22
+ check_duplicates: bool = True
23
+ check_preprint_ratio: bool = True
24
+ preprint_warning_threshold: float = 0.50
25
+
26
+
27
+ @dataclass
28
+ class WorkflowStep:
29
+ """Single step in the reference check workflow."""
30
+ name: str
31
+ enabled: bool = True
32
+ description: str = ""
33
+
34
+
35
+ @dataclass
36
+ class OutputConfig:
37
+ """Output configuration."""
38
+ quiet: bool = False
39
+ minimal_verified: bool = False
40
+
41
+
42
+ @dataclass
43
+ class CiteScanConfig:
44
+ """citescan configuration"""
45
+ files: FilesConfig = field(default_factory=FilesConfig)
46
+ bibliography: BibliographyConfig = field(default_factory=BibliographyConfig)
47
+ workflow: List[WorkflowStep] = field(default_factory=list)
48
+ output: OutputConfig = field(default_factory=OutputConfig)
49
+
50
+ _bib_files: List[Path] = field(default_factory=list)
51
+ _config_dir: Path = field(default_factory=lambda: Path.cwd())
52
+
53
+ def resolve_path(self, path: str) -> Path:
54
+ p = Path(path)
55
+ if p.is_absolute():
56
+ return p
57
+ return self._config_dir / p
58
+
59
+ @property
60
+ def bib_path(self) -> Path:
61
+ return self.resolve_path(self.files.bib)
62
+
63
+ @property
64
+ def input_dir_path(self) -> Path:
65
+ return self.resolve_path(self.files.input_dir)
66
+
67
+ @property
68
+ def output_dir_path(self) -> Path:
69
+ return self.resolve_path(self.files.output_dir)
70
+
71
+
72
+ def load_config(config_path: str) -> CiteScanConfig:
73
+ """Load configuration from YAML file."""
74
+ path = Path(config_path)
75
+ if not path.exists():
76
+ raise FileNotFoundError(f"Config file not found: {config_path}")
77
+ with open(path, "r", encoding="utf-8") as f:
78
+ data = yaml.safe_load(f) or {}
79
+
80
+ config = CiteScanConfig()
81
+ config._config_dir = path.parent.absolute()
82
+
83
+ if "files" in data:
84
+ files = data["files"]
85
+ config.files = FilesConfig(
86
+ bib=files.get("bib", ""),
87
+ input_dir=files.get("input_dir", ""),
88
+ output_dir=files.get("output_dir", "citescan_output"),
89
+ )
90
+ if "bibliography" in data:
91
+ bib = data["bibliography"]
92
+ config.bibliography = BibliographyConfig(
93
+ check_metadata=bib.get("check_metadata", True),
94
+ check_duplicates=bib.get("check_duplicates", True),
95
+ check_preprint_ratio=bib.get("check_preprint_ratio", True),
96
+ preprint_warning_threshold=bib.get("preprint_warning_threshold", 0.50),
97
+ )
98
+ if "workflow" in data:
99
+ config.workflow = [
100
+ WorkflowStep(
101
+ name=step.get("name", ""),
102
+ enabled=step.get("enabled", True),
103
+ description=step.get("description", ""),
104
+ )
105
+ for step in data["workflow"]
106
+ ]
107
+ if "output" in data:
108
+ out = data["output"]
109
+ config.output = OutputConfig(
110
+ quiet=out.get("quiet", False),
111
+ minimal_verified=out.get("minimal_verified", False),
112
+ )
113
+ return config
114
+
115
+
116
+ def find_config_file() -> Optional[Path]:
117
+ """Find config file in current directory or parent directories."""
118
+ names = ["config.yaml", "citescan.yaml", "citescan.yml", ".citescan.yaml", ".citescan.yml"]
119
+ current = Path.cwd()
120
+ for _ in range(5):
121
+ for name in names:
122
+ p = current / name
123
+ if p.exists():
124
+ return p
125
+ parent = current.parent
126
+ if parent == current:
127
+ break
128
+ current = parent
129
+ return None
130
+
131
+
132
+ def create_default_config(output_path: str = "config.yaml") -> str:
133
+ """Create a default config file (bib-only)."""
134
+ default = """# citescan Configuration (bib-only)
135
+
136
+ files:
137
+ bib: "paper.bib"
138
+ output_dir: "citescan_output"
139
+
140
+ bibliography:
141
+ check_metadata: true
142
+ check_duplicates: true
143
+ check_preprint_ratio: true
144
+ preprint_warning_threshold: 0.50
145
+
146
+ workflow:
147
+ - name: arxiv_id
148
+ enabled: true
149
+ - name: crossref_doi
150
+ enabled: true
151
+ - name: semantic_scholar
152
+ enabled: true
153
+ - name: dblp
154
+ enabled: true
155
+ - name: openalex
156
+ enabled: true
157
+ - name: arxiv_title
158
+ enabled: true
159
+ - name: crossref_title
160
+ enabled: true
161
+ - name: google_scholar
162
+ enabled: false
163
+
164
+ output:
165
+ quiet: false
166
+ minimal_verified: false
167
+ """
168
+ with open(output_path, "w", encoding="utf-8") as f:
169
+ f.write(default)
170
+ return output_path
src/fetchers/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetchers package"""
2
+ from .arxiv_fetcher import ArxivFetcher
3
+ from .scholar_fetcher import ScholarFetcher
4
+ from .crossref_fetcher import CrossRefFetcher
5
+ from .semantic_scholar_fetcher import SemanticScholarFetcher
6
+ from .openalex_fetcher import OpenAlexFetcher
7
+ from .dblp_fetcher import DBLPFetcher
8
+
9
+ __all__ = [
10
+ 'ArxivFetcher',
11
+ 'ScholarFetcher',
12
+ 'CrossRefFetcher',
13
+ 'SemanticScholarFetcher',
14
+ 'OpenAlexFetcher',
15
+ 'DBLPFetcher'
16
+ ]
src/fetchers/arxiv_fetcher.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ arXiv metadata fetcher using the public API.
3
+ """
4
+ import re
5
+ import time
6
+ import xml.etree.ElementTree as ET
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+ from urllib.parse import quote
10
+
11
+ import requests
12
+
13
+
14
+ @dataclass
15
+ class ArxivMetadata:
16
+ """Metadata fetched from arXiv."""
17
+ arxiv_id: str
18
+ title: str
19
+ authors: list[str]
20
+ abstract: str
21
+ published: str
22
+ updated: str
23
+ categories: list[str]
24
+ primary_category: str
25
+ doi: str
26
+ journal_ref: str
27
+ comment: str
28
+ pdf_url: str
29
+ abs_url: str
30
+
31
+ @property
32
+ def year(self) -> str:
33
+ """Extract year from published date."""
34
+ if self.published:
35
+ match = re.match(r'(\d{4})', self.published)
36
+ if match:
37
+ return match.group(1)
38
+ return ""
39
+
40
+
41
+ class ArxivFetcher:
42
+ """Fetches metadata from arXiv API."""
43
+
44
+ API_BASE = "http://export.arxiv.org/api/query"
45
+ RATE_LIMIT_DELAY = 3.0 # seconds between requests
46
+
47
+ def __init__(self):
48
+ self._last_request_time = 0.0
49
+
50
+ def _rate_limit(self):
51
+ """Ensure rate limiting between requests."""
52
+ elapsed = time.time() - self._last_request_time
53
+ if elapsed < self.RATE_LIMIT_DELAY:
54
+ time.sleep(self.RATE_LIMIT_DELAY - elapsed)
55
+ self._last_request_time = time.time()
56
+
57
+ def fetch_by_id(self, arxiv_id: str) -> Optional[ArxivMetadata]:
58
+ """Fetch metadata by arXiv ID."""
59
+ # Clean up ID
60
+ arxiv_id = arxiv_id.strip()
61
+ arxiv_id = re.sub(r'^arXiv:', '', arxiv_id, flags=re.IGNORECASE)
62
+
63
+ self._rate_limit()
64
+
65
+ params = {
66
+ 'id_list': arxiv_id,
67
+ 'max_results': 1
68
+ }
69
+
70
+ try:
71
+ response = requests.get(
72
+ self.API_BASE,
73
+ params=params,
74
+ timeout=30,
75
+ headers={'User-Agent': 'BibChecker/1.0 (mailto:user@example.com)'}
76
+ )
77
+ response.raise_for_status()
78
+ except requests.RequestException as e:
79
+ return None
80
+
81
+ return self._parse_response(response.text)
82
+
83
+ def search_by_title(self, title: str, max_results: int = 5) -> list[ArxivMetadata]:
84
+ """Search arXiv by title."""
85
+ self._rate_limit()
86
+
87
+ # Clean up title for search
88
+ clean_title = re.sub(r'[^\w\s]', ' ', title)
89
+ clean_title = re.sub(r'\s+', ' ', clean_title).strip()
90
+
91
+ # Build search query
92
+ search_query = f'ti:"{clean_title}"'
93
+
94
+ params = {
95
+ 'search_query': search_query,
96
+ 'max_results': max_results,
97
+ 'sortBy': 'relevance',
98
+ 'sortOrder': 'descending'
99
+ }
100
+
101
+ try:
102
+ response = requests.get(
103
+ self.API_BASE,
104
+ params=params,
105
+ timeout=30,
106
+ headers={'User-Agent': 'BibChecker/1.0 (mailto:user@example.com)'}
107
+ )
108
+ response.raise_for_status()
109
+ except requests.RequestException as e:
110
+ return []
111
+
112
+ return self._parse_response_multiple(response.text)
113
+
114
+ def _parse_response(self, xml_content: str) -> Optional[ArxivMetadata]:
115
+ """Parse single entry response."""
116
+ results = self._parse_response_multiple(xml_content)
117
+ return results[0] if results else None
118
+
119
+ def _parse_response_multiple(self, xml_content: str) -> list[ArxivMetadata]:
120
+ """Parse multiple entries from response."""
121
+ results = []
122
+
123
+ try:
124
+ root = ET.fromstring(xml_content)
125
+ except ET.ParseError:
126
+ return results
127
+
128
+ # Define namespaces
129
+ ns = {
130
+ 'atom': 'http://www.w3.org/2005/Atom',
131
+ 'arxiv': 'http://arxiv.org/schemas/atom'
132
+ }
133
+
134
+ entries = root.findall('atom:entry', ns)
135
+
136
+ for entry in entries:
137
+ try:
138
+ metadata = self._parse_entry(entry, ns)
139
+ if metadata:
140
+ results.append(metadata)
141
+ except Exception:
142
+ continue
143
+
144
+ return results
145
+
146
+ def _parse_entry(self, entry: ET.Element, ns: dict) -> Optional[ArxivMetadata]:
147
+ """Parse a single entry element."""
148
+ # Get ID
149
+ id_elem = entry.find('atom:id', ns)
150
+ if id_elem is None or id_elem.text is None:
151
+ return None
152
+
153
+ abs_url = id_elem.text.strip()
154
+
155
+ # Extract arXiv ID from URL
156
+ match = re.search(r'arxiv\.org/abs/(.+)$', abs_url)
157
+ arxiv_id = match.group(1) if match else ""
158
+
159
+ # Get title
160
+ title_elem = entry.find('atom:title', ns)
161
+ title = self._clean_text(title_elem.text) if title_elem is not None and title_elem.text else ""
162
+
163
+ # Get abstract
164
+ summary_elem = entry.find('atom:summary', ns)
165
+ abstract = self._clean_text(summary_elem.text) if summary_elem is not None and summary_elem.text else ""
166
+
167
+ # Get authors
168
+ authors = []
169
+ for author_elem in entry.findall('atom:author', ns):
170
+ name_elem = author_elem.find('atom:name', ns)
171
+ if name_elem is not None and name_elem.text:
172
+ authors.append(name_elem.text.strip())
173
+
174
+ # Get dates
175
+ published_elem = entry.find('atom:published', ns)
176
+ published = published_elem.text.strip() if published_elem is not None and published_elem.text else ""
177
+
178
+ updated_elem = entry.find('atom:updated', ns)
179
+ updated = updated_elem.text.strip() if updated_elem is not None and updated_elem.text else ""
180
+
181
+ # Get categories
182
+ categories = []
183
+ for cat_elem in entry.findall('atom:category', ns):
184
+ term = cat_elem.get('term')
185
+ if term:
186
+ categories.append(term)
187
+
188
+ primary_cat_elem = entry.find('arxiv:primary_category', ns)
189
+ primary_category = primary_cat_elem.get('term', '') if primary_cat_elem is not None else ""
190
+
191
+ # Get DOI
192
+ doi_elem = entry.find('arxiv:doi', ns)
193
+ doi = doi_elem.text.strip() if doi_elem is not None and doi_elem.text else ""
194
+
195
+ # Get journal reference
196
+ journal_elem = entry.find('arxiv:journal_ref', ns)
197
+ journal_ref = journal_elem.text.strip() if journal_elem is not None and journal_elem.text else ""
198
+
199
+ # Get comment
200
+ comment_elem = entry.find('arxiv:comment', ns)
201
+ comment = comment_elem.text.strip() if comment_elem is not None and comment_elem.text else ""
202
+
203
+ # Build PDF URL
204
+ pdf_url = abs_url.replace('/abs/', '/pdf/') + '.pdf'
205
+
206
+ return ArxivMetadata(
207
+ arxiv_id=arxiv_id,
208
+ title=title,
209
+ authors=authors,
210
+ abstract=abstract,
211
+ published=published,
212
+ updated=updated,
213
+ categories=categories,
214
+ primary_category=primary_category,
215
+ doi=doi,
216
+ journal_ref=journal_ref,
217
+ comment=comment,
218
+ pdf_url=pdf_url,
219
+ abs_url=abs_url
220
+ )
221
+
222
+ def _clean_text(self, text: str) -> str:
223
+ """Clean up text from XML."""
224
+ if not text:
225
+ return ""
226
+ # Normalize whitespace
227
+ text = re.sub(r'\s+', ' ', text)
228
+ return text.strip()
src/fetchers/crossref_fetcher.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CrossRef API fetcher for bibliography metadata.
3
+
4
+ CrossRef provides free, reliable access to metadata for academic publications.
5
+ No API key required, no rate limiting for reasonable use.
6
+ """
7
+ import requests
8
+ from dataclasses import dataclass
9
+ from typing import Optional, List
10
+ import time
11
+
12
+
13
+ @dataclass
14
+ class CrossRefResult:
15
+ """Metadata result from CrossRef API."""
16
+ title: str
17
+ authors: List[str]
18
+ year: str
19
+ doi: str
20
+ publisher: str
21
+ container_title: str # Journal/conference name
22
+ abstract: str = ""
23
+ url: str = ""
24
+
25
+
26
+ class CrossRefFetcher:
27
+ """
28
+ Fetcher for CrossRef API.
29
+
30
+ CrossRef is a reliable, free API for academic metadata.
31
+ Much more reliable than Google Scholar scraping.
32
+ """
33
+
34
+ BASE_URL = "https://api.crossref.org/works"
35
+ RATE_LIMIT_DELAY = 1.0 # Be polite
36
+
37
+ def __init__(self, mailto: str = "e1143641@u.nus.edu"):
38
+ """
39
+ Initialize CrossRef fetcher.
40
+
41
+ Args:
42
+ mailto: Email for polite pool (gets better rate limits)
43
+ """
44
+ self.mailto = mailto
45
+ self._last_request_time = 0.0
46
+ self._session = requests.Session()
47
+
48
+ def _rate_limit(self):
49
+ """Ensure rate limiting between requests."""
50
+ elapsed = time.time() - self._last_request_time
51
+ if elapsed < self.RATE_LIMIT_DELAY:
52
+ time.sleep(self.RATE_LIMIT_DELAY - elapsed)
53
+ self._last_request_time = time.time()
54
+
55
+ def _get_headers(self) -> dict:
56
+ """Get request headers with mailto for polite pool."""
57
+ return {
58
+ 'User-Agent': f'CiteScan/1.0 (mailto:{self.mailto})',
59
+ 'Accept': 'application/json',
60
+ }
61
+
62
+ def search_by_title(self, title: str, max_results: int = 5) -> Optional[CrossRefResult]:
63
+ """
64
+ Search for a paper by title.
65
+
66
+ Args:
67
+ title: Paper title to search for
68
+ max_results: Maximum number of results to retrieve
69
+
70
+ Returns:
71
+ Best matching CrossRefResult or None if not found
72
+ """
73
+ self._rate_limit()
74
+
75
+ params = {
76
+ 'query.title': title,
77
+ 'rows': max_results,
78
+ 'select': 'title,author,published-print,published-online,DOI,publisher,container-title,abstract'
79
+ }
80
+
81
+ try:
82
+ response = self._session.get(
83
+ self.BASE_URL,
84
+ params=params,
85
+ headers=self._get_headers(),
86
+ timeout=30
87
+ )
88
+ response.raise_for_status()
89
+
90
+ data = response.json()
91
+
92
+ if data.get('status') != 'ok':
93
+ return None
94
+
95
+ items = data.get('message', {}).get('items', [])
96
+
97
+ if not items:
98
+ return None
99
+
100
+ # Return best match (first result, as CrossRef ranks by relevance)
101
+ return self._parse_item(items[0])
102
+
103
+ except requests.RequestException:
104
+ return None
105
+
106
+ def search_by_doi(self, doi: str) -> Optional[CrossRefResult]:
107
+ """
108
+ Fetch metadata by DOI.
109
+
110
+ Args:
111
+ doi: DOI of the paper
112
+
113
+ Returns:
114
+ CrossRefResult or None if not found
115
+ """
116
+ self._rate_limit()
117
+
118
+ # Clean DOI (remove https://doi.org/ prefix if present)
119
+ doi = doi.replace('https://doi.org/', '').replace('http://doi.org/', '')
120
+
121
+ try:
122
+ response = self._session.get(
123
+ f"{self.BASE_URL}/{doi}",
124
+ headers=self._get_headers(),
125
+ timeout=30
126
+ )
127
+ response.raise_for_status()
128
+
129
+ data = response.json()
130
+
131
+ if data.get('status') != 'ok':
132
+ return None
133
+
134
+ item = data.get('message', {})
135
+ return self._parse_item(item)
136
+
137
+ except requests.RequestException:
138
+ return None
139
+
140
+ def _parse_item(self, item: dict) -> Optional[CrossRefResult]:
141
+ """Parse a CrossRef API item into CrossRefResult."""
142
+ try:
143
+ # Get title
144
+ titles = item.get('title', [])
145
+ title = titles[0] if titles else ""
146
+
147
+ if not title:
148
+ return None
149
+
150
+ # Get authors
151
+ authors = []
152
+ for author in item.get('author', []):
153
+ given_name = author.get('given', '')
154
+ family_name = author.get('family', '')
155
+ if family_name:
156
+ if given_name:
157
+ authors.append(f"{given_name} {family_name}")
158
+ else:
159
+ authors.append(family_name)
160
+
161
+ # Get year (try published-print first, then published-online)
162
+ year = ""
163
+ for date_field in ['published-print', 'published-online', 'created']:
164
+ date_parts = item.get(date_field, {}).get('date-parts', [[]])
165
+ if date_parts and date_parts[0]:
166
+ year = str(date_parts[0][0])
167
+ break
168
+
169
+ # Get DOI
170
+ doi = item.get('DOI', '')
171
+
172
+ # Get publisher
173
+ publisher = item.get('publisher', '')
174
+
175
+ # Get container title (journal/conference name)
176
+ container_titles = item.get('container-title', [])
177
+ container_title = container_titles[0] if container_titles else ""
178
+
179
+ # Get abstract (if available)
180
+ abstract = item.get('abstract', '')
181
+
182
+ # Build URL
183
+ url = f"https://doi.org/{doi}" if doi else ""
184
+
185
+ return CrossRefResult(
186
+ title=title,
187
+ authors=authors,
188
+ year=year,
189
+ doi=doi,
190
+ publisher=publisher,
191
+ container_title=container_title,
192
+ abstract=abstract,
193
+ url=url
194
+ )
195
+
196
+ except (KeyError, IndexError, TypeError):
197
+ return None
198
+
199
+ # TODO: Use API to fetch crossref results
200
+ if __name__ == "__main__":
201
+ crossref_fetcher = CrossRefFetcher(mailto="e1143641@u.nus.edu")
202
+ results = crossref_fetcher.search_by_title("Zero Bubble Pipeline Parallelism")
203
+ print('results: \n', results)
src/fetchers/dblp_fetcher.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import time
3
+ import logging
4
+ from typing import Optional, List, Dict, Any
5
+ from dataclasses import dataclass
6
+
7
+ @dataclass
8
+ class DBLPResult:
9
+ title: str
10
+ authors: List[str]
11
+ year: str
12
+ venue: str
13
+ url: str
14
+ doi: Optional[str] = None
15
+
16
+ class DBLPFetcher:
17
+ """Fetcher for DBLP API."""
18
+
19
+ BASE_URL = "https://dblp.org/search/publ/api"
20
+
21
+ def __init__(self):
22
+ self.last_request_time = 0
23
+ # DBLP asks for 1-2 seconds between requests. We'll use 1.5s to be safe.
24
+ self.rate_limit_delay = 1.5
25
+ self.logger = logging.getLogger(__name__)
26
+
27
+ def _wait_for_rate_limit(self):
28
+ """Ensure we don't exceed rate limits."""
29
+ elapsed = time.time() - self.last_request_time
30
+ if elapsed < self.rate_limit_delay:
31
+ time.sleep(self.rate_limit_delay - elapsed)
32
+ self.last_request_time = time.time()
33
+
34
+ def search_by_title(self, title: str) -> Optional[DBLPResult]:
35
+ """Search DBLP by title.
36
+ reference: https://dblp.org/faq/How+to+use+the+dblp+search+API.html
37
+ """
38
+ self._wait_for_rate_limit()
39
+
40
+ params = {
41
+ "q": title,
42
+ "format": "json",
43
+ "h": 5
44
+ }
45
+
46
+ try:
47
+ response = requests.get(self.BASE_URL, params=params, timeout=10)
48
+
49
+ if response.status_code == 429:
50
+ self.logger.warning("DBLP rate limit exceeded. Waiting longer...")
51
+ time.sleep(5)
52
+ return None
53
+
54
+ if response.status_code != 200:
55
+ self.logger.warning(f"DBLP API error: {response.status_code}")
56
+ return None
57
+
58
+ data = response.json()
59
+ return self._parse_response(data, title)
60
+
61
+ except Exception as e:
62
+ self.logger.error(f"Error fetching from DBLP: {e}")
63
+ return None
64
+
65
+ def _parse_response(self, data: Dict[str, Any], query_title: str) -> Optional[DBLPResult]:
66
+ """Parse DBLP JSON response."""
67
+ try:
68
+ result = data.get("result", {})
69
+ hits = result.get("hits", {}).get("hit", [])
70
+
71
+ if not hits:
72
+ return None
73
+
74
+ # Find best match
75
+ best_hit = None
76
+
77
+ # Simple check: first hit is usually the best in DBLP for exact title match
78
+ # But we can do a quick normalization check if needed.
79
+ # For now, let's take the first hit that is a publication (not a person/venue)
80
+ # The search/publ/api endpoint should only return publications.
81
+
82
+ best_hit = hits[0]
83
+ info = best_hit.get("info", {})
84
+
85
+ # Extract authors
86
+ authors_data = info.get("authors", {}).get("author", [])
87
+ authors = []
88
+ if isinstance(authors_data, list):
89
+ authors = [a.get("text", "") for a in authors_data]
90
+ elif isinstance(authors_data, dict):
91
+ authors = [authors_data.get("text", "")]
92
+
93
+ # Extract other fields
94
+ title = info.get("title", "")
95
+ year = info.get("year", "")
96
+ venue = info.get("venue", "")
97
+ url = info.get("url", "")
98
+ doi = info.get("doi", "")
99
+
100
+ # Clean title (DBLP titles often end with a dot)
101
+ if title.endswith("."):
102
+ title = title[:-1]
103
+
104
+ return DBLPResult(
105
+ title=title,
106
+ authors=authors,
107
+ year=year,
108
+ venue=venue,
109
+ url=url,
110
+ doi=doi if doi else None
111
+ )
112
+
113
+ except Exception as e:
114
+ self.logger.error(f"Error parsing DBLP response: {e}")
115
+ return None
116
+
117
+ # TODO: Use API to fetch dblp results
118
+ if __name__ == "__main__":
119
+ dblp_fetcher = DBLPFetcher()
120
+ results = dblp_fetcher.search_by_title("Zero Bubble Pipeline Parallelism")
121
+ print('results: \n', results)
src/fetchers/openalex_fetcher.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAlex API fetcher.
3
+ Free and open API for scholarly metadata.
4
+ """
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+
12
+ @dataclass
13
+ class OpenAlexResult:
14
+ """Search result from OpenAlex API."""
15
+ title: str
16
+ authors: list[str]
17
+ year: str
18
+ abstract: str
19
+ doi: str
20
+ citation_count: int
21
+ url: str
22
+
23
+
24
+ class OpenAlexFetcher:
25
+ """
26
+ Fetcher using OpenAlex's free API.
27
+
28
+ API Docs: https://docs.openalex.org/
29
+ Rate Limits:
30
+ - 100,000 requests per day
31
+ - 10 requests per second (very generous)
32
+ - No API key required (but polite pool recommended)
33
+ """
34
+
35
+ BASE_URL = "https://api.openalex.org"
36
+ RATE_LIMIT_DELAY = 0.1 # 10 req/sec max
37
+
38
+ def __init__(self, email: Optional[str] = None):
39
+ """
40
+ Initialize OpenAlex fetcher.
41
+
42
+ Args:
43
+ email: Optional email for polite pool (faster rate limits)
44
+ """
45
+ self.email = email
46
+ self._last_request_time = 0.0
47
+ self._session = requests.Session()
48
+
49
+ # Set user agent (required by OpenAlex)
50
+ self._session.headers.update({
51
+ 'User-Agent': 'CiteScan/1.0 (https://github.com/aivolcano/CiteScan; mailto:e1143641@u.nus.edu)'
52
+ })
53
+
54
+ # Add email to polite pool if provided
55
+ if email:
56
+ self._session.headers.update({'From': email})
57
+
58
+ def _rate_limit(self):
59
+ """Ensure rate limiting between requests."""
60
+ elapsed = time.time() - self._last_request_time
61
+ if elapsed < self.RATE_LIMIT_DELAY:
62
+ time.sleep(self.RATE_LIMIT_DELAY - elapsed)
63
+ self._last_request_time = time.time()
64
+
65
+ def search_by_title(self, title: str, max_results: int = 5) -> Optional[OpenAlexResult]:
66
+ """
67
+ Search for a paper by title.
68
+
69
+ Args:
70
+ title: Paper title to search for
71
+ max_results: Maximum number of results to fetch (default: 5)
72
+
73
+ Returns:
74
+ OpenAlexResult if found, None otherwise
75
+ """
76
+ self._rate_limit()
77
+
78
+ url = f"{self.BASE_URL}/works"
79
+ params = {
80
+ 'search': title,
81
+ 'per-page': max_results
82
+ }
83
+
84
+ try:
85
+ response = self._session.get(url, params=params, timeout=10)
86
+ response.raise_for_status()
87
+ data = response.json()
88
+
89
+ results = data.get('results', [])
90
+ if not results:
91
+ return None
92
+
93
+ # Return the first (most relevant) result
94
+ return self._parse_work(results[0])
95
+
96
+ except requests.RequestException:
97
+ return None
98
+
99
+ def fetch_by_doi(self, doi: str) -> Optional[OpenAlexResult]:
100
+ """
101
+ Fetch paper metadata by DOI.
102
+
103
+ Args:
104
+ doi: DOI of the paper
105
+
106
+ Returns:
107
+ OpenAlexResult if found, None otherwise
108
+ """
109
+ self._rate_limit()
110
+
111
+ # OpenAlex uses DOI URLs
112
+ doi_url = f"https://doi.org/{doi}"
113
+ url = f"{self.BASE_URL}/works/{doi_url}"
114
+
115
+ try:
116
+ response = self._session.get(url, timeout=10)
117
+ response.raise_for_status()
118
+ data = response.json()
119
+ return self._parse_work(data)
120
+
121
+ except requests.RequestException:
122
+ return None
123
+
124
+ def _parse_work(self, work_data: dict) -> Optional[OpenAlexResult]:
125
+ """Parse work data from API response."""
126
+ try:
127
+ # Extract title
128
+ title = work_data.get('title', '')
129
+
130
+ # Extract authors
131
+ authors = []
132
+ authorships = work_data.get('authorships', [])
133
+ for authorship in authorships:
134
+ author = authorship.get('author', {})
135
+ name = author.get('display_name', '')
136
+ if name:
137
+ authors.append(name)
138
+
139
+ # Get publication year
140
+ year = work_data.get('publication_year')
141
+ year_str = str(year) if year else ""
142
+
143
+ # Get abstract (inverted index format)
144
+ abstract = ""
145
+ abstract_inverted = work_data.get('abstract_inverted_index')
146
+ if abstract_inverted:
147
+ # Reconstruct abstract from inverted index
148
+ abstract = self._reconstruct_abstract(abstract_inverted)
149
+
150
+ # Get DOI
151
+ doi = work_data.get('doi', '')
152
+ if doi and doi.startswith('https://doi.org/'):
153
+ doi = doi.replace('https://doi.org/', '')
154
+
155
+ # Get citation count
156
+ citation_count = work_data.get('cited_by_count', 0)
157
+
158
+ # Get URL
159
+ url = work_data.get('id', '') # OpenAlex ID URL
160
+
161
+ return OpenAlexResult(
162
+ title=title,
163
+ authors=authors,
164
+ year=year_str,
165
+ abstract=abstract,
166
+ doi=doi,
167
+ citation_count=citation_count,
168
+ url=url
169
+ )
170
+ except (KeyError, TypeError):
171
+ return None
172
+
173
+ def _reconstruct_abstract(self, inverted_index: dict) -> str:
174
+ """
175
+ Reconstruct abstract text from inverted index.
176
+
177
+ OpenAlex stores abstracts in inverted index format:
178
+ {"word": [position1, position2, ...], ...}
179
+ """
180
+ if not inverted_index:
181
+ return ""
182
+
183
+ try:
184
+ # Create a list to hold words at their positions
185
+ max_pos = max(max(positions) for positions in inverted_index.values())
186
+ words = [''] * (max_pos + 1)
187
+
188
+ # Place each word at its positions
189
+ for word, positions in inverted_index.items():
190
+ for pos in positions:
191
+ words[pos] = word
192
+
193
+ # Join words with spaces
194
+ return ' '.join(word for word in words if word)
195
+ except (ValueError, TypeError):
196
+ return ""
197
+
198
+ if __name__ == "__main__":
199
+ openalex_fetcher = OpenAlexFetcher(email="e1143641@u.nus.edu")
200
+ results = openalex_fetcher.search_by_title("Zero Bubble Pipeline Parallelism")
201
+ print('results: \n', results)
src/fetchers/scholar_fetcher.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google Scholar search (scraping-based fallback).
3
+ """
4
+ import re
5
+ import time
6
+ import random
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import requests
11
+ from bs4 import BeautifulSoup
12
+
13
+
14
+ @dataclass
15
+ class ScholarResult:
16
+ """Search result from Google Scholar."""
17
+ title: str
18
+ authors: str
19
+ year: str
20
+ snippet: str
21
+ url: str
22
+ cited_by: int
23
+
24
+
25
+ class ScholarFetcher:
26
+ """
27
+ Fallback fetcher using Google Scholar search.
28
+
29
+ Note: This uses scraping and may be blocked.
30
+ Use rate limiting and respect robots.txt.
31
+ """
32
+
33
+ SEARCH_URL = "https://scholar.google.com/scholar"
34
+ RATE_LIMIT_DELAY = 10.0 # Conservative delay to avoid blocking (was 5.0)
35
+ MAX_RETRIES = 2 # Retry on failures
36
+
37
+ USER_AGENTS = [
38
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
39
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
40
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
41
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
42
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
43
+ ]
44
+
45
+ def __init__(self):
46
+ self._last_request_time = 0.0
47
+ self._session = requests.Session()
48
+ self._request_count = 0
49
+ self._blocked = False # Track if we've been blocked
50
+
51
+ def _rate_limit(self):
52
+ """Ensure rate limiting between requests."""
53
+ elapsed = time.time() - self._last_request_time
54
+ # Add more randomness to avoid detection (3-5 seconds extra)
55
+ delay = self.RATE_LIMIT_DELAY + random.uniform(3, 5)
56
+ if elapsed < delay:
57
+ time.sleep(delay - elapsed)
58
+ self._last_request_time = time.time()
59
+
60
+ def _get_headers(self) -> dict:
61
+ """Get request headers with random user agent."""
62
+ return {
63
+ 'User-Agent': random.choice(self.USER_AGENTS),
64
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
65
+ 'Accept-Language': 'en-US,en;q=0.5',
66
+ 'Accept-Encoding': 'gzip, deflate',
67
+ 'Connection': 'keep-alive',
68
+ 'Upgrade-Insecure-Requests': '1',
69
+ }
70
+
71
+ def search(self, query: str, max_results: int = 5) -> list[ScholarResult]:
72
+ """
73
+ Search Google Scholar.
74
+
75
+ Returns list of search results.
76
+ Note: This may fail if blocked by Google.
77
+ """
78
+ # If we've been blocked, don't waste time
79
+ if self._blocked:
80
+ return []
81
+
82
+ self._rate_limit()
83
+ self._request_count += 1
84
+
85
+ params = {
86
+ 'q': query,
87
+ 'hl': 'en',
88
+ 'num': min(max_results, 10) # Scholar max is 10 per page
89
+ }
90
+
91
+ try:
92
+ response = self._session.get(
93
+ self.SEARCH_URL,
94
+ params=params,
95
+ headers=self._get_headers(),
96
+ timeout=30
97
+ )
98
+ response.raise_for_status()
99
+ except requests.RequestException as e:
100
+ return []
101
+
102
+ # Check if we're blocked
103
+ if 'unusual traffic' in response.text.lower() or response.status_code == 429:
104
+ self._blocked = True
105
+ print(f"⚠️ Google Scholar blocked after {self._request_count} requests. Skipping further Scholar queries.")
106
+ return []
107
+
108
+ return self._parse_results(response.text, max_results)
109
+
110
+ def search_by_title(self, title: str) -> Optional[ScholarResult]:
111
+ """Search for a specific paper by title."""
112
+ # Use quotes for exact title match
113
+ query = f'"{title}"'
114
+ results = self.search(query, max_results=3)
115
+
116
+ if not results:
117
+ # Try without quotes
118
+ results = self.search(title, max_results=5)
119
+
120
+ return results[0] if results else None
121
+
122
+ def _parse_results(self, html: str, max_results: int) -> list[ScholarResult]:
123
+ """Parse search results from HTML."""
124
+ results = []
125
+ soup = BeautifulSoup(html, 'lxml')
126
+
127
+ # Find all result entries
128
+ entries = soup.find_all('div', class_='gs_ri')
129
+
130
+ for entry in entries[:max_results]:
131
+ try:
132
+ result = self._parse_entry(entry)
133
+ if result:
134
+ results.append(result)
135
+ except Exception:
136
+ continue
137
+
138
+ return results
139
+
140
+ def _parse_entry(self, entry) -> Optional[ScholarResult]:
141
+ """Parse a single search result entry."""
142
+ # Get title
143
+ title_elem = entry.find('h3', class_='gs_rt')
144
+ if not title_elem:
145
+ return None
146
+
147
+ # Get title text and URL
148
+ title_link = title_elem.find('a')
149
+ if title_link:
150
+ title = title_link.get_text(strip=True)
151
+ url = title_link.get('href', '')
152
+ else:
153
+ title = title_elem.get_text(strip=True)
154
+ url = ''
155
+
156
+ # Clean title (remove [PDF], [HTML] markers)
157
+ title = re.sub(r'^\[(PDF|HTML|BOOK|CITATION)\]\s*', '', title)
158
+
159
+ # Get authors and year from the green line
160
+ meta_elem = entry.find('div', class_='gs_a')
161
+ authors = ""
162
+ year = ""
163
+
164
+ if meta_elem:
165
+ meta_text = meta_elem.get_text(strip=True)
166
+
167
+ # Extract year first
168
+ year_match = re.search(r'\b(19|20)\d{2}\b', meta_text)
169
+ if year_match:
170
+ year = year_match.group(0)
171
+
172
+ # Parse authors more carefully
173
+ # Format is usually: "Author1, Author2 - Journal, Year - Publisher"
174
+ # or sometimes: "Author1, Author2 - Journal/Conference - Year"
175
+ parts = meta_text.split(' - ')
176
+ if parts:
177
+ author_part = parts[0].strip()
178
+
179
+ # Clean up author field - remove year if it leaked in
180
+ if year:
181
+ # Remove year and anything after it from author field
182
+ author_part = re.sub(r',?\s*' + re.escape(year) + r'.*$', '', author_part)
183
+
184
+ # Remove common journal/venue keywords that might have leaked
185
+ # Handle patterns like "the journal of", "the proceedings", etc.
186
+ author_part = re.sub(r'\s+the\s+(journal|proceedings|conference|symposium|workshop|transactions|magazine|review|annals)\s+.*$', '', author_part, flags=re.IGNORECASE)
187
+
188
+ # Also handle without "the" prefix
189
+ author_part = re.sub(r'\s+(journal|proceedings|conference|symposium|workshop|transactions|magazine|review|annals)\s+.*$', '', author_part, flags=re.IGNORECASE)
190
+
191
+ # Remove standalone "the" at the end (in case it's left over)
192
+ author_part = re.sub(r'\s+the\s*$', '', author_part, flags=re.IGNORECASE)
193
+
194
+ # Remove trailing commas and whitespace
195
+ author_part = author_part.rstrip(', ').strip()
196
+
197
+ authors = author_part
198
+
199
+ # Get snippet
200
+ snippet_elem = entry.find('div', class_='gs_rs')
201
+ snippet = snippet_elem.get_text(strip=True) if snippet_elem else ""
202
+
203
+ # Get cited by count
204
+ cited_by = 0
205
+ cited_elem = entry.find('a', string=re.compile(r'Cited by \d+'))
206
+ if cited_elem:
207
+ match = re.search(r'Cited by (\d+)', cited_elem.get_text())
208
+ if match:
209
+ cited_by = int(match.group(1))
210
+
211
+ return ScholarResult(
212
+ title=title,
213
+ authors=authors,
214
+ year=year,
215
+ snippet=snippet,
216
+ url=url,
217
+ cited_by=cited_by
218
+ )
219
+
220
+ # TODO: Use API to fetch scholar results
221
+ # Fix the code, and provide the serper api key to fetch scholar results
222
+ if __name__ == "__main__":
223
+ scholar_fetcher = ScholarFetcher()
224
+ results = scholar_fetcher.search("Zero Bubble Pipeline Parallelism")
225
+ print('results: \n', results)
src/fetchers/semantic_scholar_fetcher.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Semantic Scholar API fetcher.
3
+ Official API with high quality metadata and generous rate limits.
4
+ """
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ import requests, random
10
+
11
+
12
+ @dataclass
13
+ class SemanticScholarResult:
14
+ """Search result from Semantic Scholar API."""
15
+ title: str
16
+ authors: list[str]
17
+ year: str
18
+ abstract: str
19
+ paper_id: str
20
+ citation_count: int
21
+ url: str
22
+
23
+
24
+ class SemanticScholarFetcher:
25
+ """
26
+ Fetcher using Semantic Scholar's official API.
27
+
28
+ API Docs: https://api.semanticscholar.org/
29
+ Rate Limits:
30
+ - Without API key: 100 requests per 5 minutes
31
+ - With API key: 5,000 requests per 5 minutes (free)
32
+ """
33
+
34
+ BASE_URL = "https://api.semanticscholar.org/graph/v1"
35
+ RATE_LIMIT_DELAY = 0.5 # Conservative delay (120 req/min max)
36
+
37
+ def __init__(self, api_key: Optional[str] = None):
38
+ """
39
+ Initialize Semantic Scholar fetcher.
40
+
41
+ Args:
42
+ api_key: Optional API key for higher rate limits (free from semanticscholar.org)
43
+ """
44
+ api_keys = ["oK11tdfAXm5Sdn2wZi2zA24xstu8iE3G6kqR4Jqj","LlAtlGzau01reNF1M8F754BXoVLpkhys78xVA5Mn","ZtJ3CQtjiWQL8iN5NUaU2QMdd8IIglP8RtikS6n7","HZjLYgpG863xuekLReTec7WFXSC78EFg44aPsmpq","I87z0IQQyJ11i3nVMV3fU39VIaM1DCOx8DUuMc9B","DNX92WcQcz8qV2NNABb3T6RrbFBWhy3W5lgbiwsH"]
45
+ self._last_request_time = 0.0
46
+ self._session = requests.Session()
47
+
48
+ self._session.headers.update({'x-api-key': random.choice(api_keys)})
49
+
50
+ def _rate_limit(self):
51
+ """Ensure rate limiting between requests."""
52
+ elapsed = time.time() - self._last_request_time
53
+ if elapsed < self.RATE_LIMIT_DELAY:
54
+ time.sleep(self.RATE_LIMIT_DELAY - elapsed)
55
+ self._last_request_time = time.time()
56
+
57
+ def search_by_title(self, title: str, max_results: int = 5) -> Optional[SemanticScholarResult]:
58
+ """
59
+ Search for a paper by title.
60
+
61
+ Args:
62
+ title: Paper title to search for
63
+ max_results: Maximum number of results to fetch (default: 5)
64
+
65
+ Returns:
66
+ SemanticScholarResult if found, None otherwise
67
+ """
68
+ self._rate_limit()
69
+
70
+ url = f"{self.BASE_URL}/paper/search"
71
+ params = {
72
+ 'query': title,
73
+ 'limit': max_results,
74
+ 'fields': 'title,authors,year,abstract,paperId,citationCount,url'
75
+ }
76
+
77
+ try:
78
+ response = self._session.get(url, params=params, timeout=10)
79
+ response.raise_for_status()
80
+ data = response.json()
81
+
82
+ papers = data.get('data', [])
83
+ if not papers:
84
+ return None
85
+
86
+ # Return the first (most relevant) result
87
+ return self._parse_paper(papers[0])
88
+
89
+ except requests.RequestException:
90
+ return None
91
+
92
+ def fetch_by_doi(self, doi: str) -> Optional[SemanticScholarResult]:
93
+ """
94
+ Fetch paper metadata by DOI.
95
+
96
+ Args:
97
+ doi: DOI of the paper
98
+
99
+ Returns:
100
+ SemanticScholarResult if found, None otherwise
101
+ """
102
+ self._rate_limit()
103
+
104
+ url = f"{self.BASE_URL}/paper/DOI:{doi}"
105
+ params = {
106
+ 'fields': 'title,authors,year,abstract,paperId,citationCount,url'
107
+ }
108
+
109
+ try:
110
+ response = self._session.get(url, params=params, timeout=10)
111
+ response.raise_for_status()
112
+ data = response.json()
113
+ return self._parse_paper(data)
114
+
115
+ except requests.RequestException:
116
+ return None
117
+
118
+ def fetch_by_arxiv_id(self, arxiv_id: str) -> Optional[SemanticScholarResult]:
119
+ """
120
+ Fetch paper metadata by arXiv ID.
121
+
122
+ Args:
123
+ arxiv_id: arXiv ID (e.g., "2301.12345" or "arXiv:2301.12345")
124
+
125
+ Returns:
126
+ SemanticScholarResult if found, None otherwise
127
+ """
128
+ self._rate_limit()
129
+
130
+ # Clean arXiv ID (remove "arXiv:" prefix if present)
131
+ clean_id = arxiv_id.replace('arXiv:', '')
132
+
133
+ url = f"{self.BASE_URL}/paper/ARXIV:{clean_id}"
134
+ params = {
135
+ 'fields': 'title,authors,year,abstract,paperId,citationCount,url'
136
+ }
137
+
138
+ try:
139
+ response = self._session.get(url, params=params, timeout=10)
140
+ response.raise_for_status()
141
+ data = response.json()
142
+ return self._parse_paper(data)
143
+
144
+ except requests.RequestException:
145
+ return None
146
+
147
+ def _parse_paper(self, paper_data: dict) -> Optional[SemanticScholarResult]:
148
+ """Parse paper data from API response."""
149
+ try:
150
+ # Extract author names
151
+ authors = []
152
+ for author in paper_data.get('authors', []):
153
+ name = author.get('name', '')
154
+ if name:
155
+ authors.append(name)
156
+
157
+ # Get year (may be None)
158
+ year = paper_data.get('year')
159
+ year_str = str(year) if year else ""
160
+
161
+ return SemanticScholarResult(
162
+ title=paper_data.get('title', ''),
163
+ authors=authors,
164
+ year=year_str,
165
+ abstract=paper_data.get('abstract', ''),
166
+ paper_id=paper_data.get('paperId', ''),
167
+ citation_count=paper_data.get('citationCount', 0),
168
+ url=paper_data.get('url', '')
169
+ )
170
+ except (KeyError, TypeError):
171
+ return None
172
+
173
+ # TODO: Use API to fetch semantic scholar results
174
+ if __name__ == "__main__":
175
+ semantic_scholar_fetcher = SemanticScholarFetcher(api_key="oK11tdfAXm5Sdn2wZi2zA24xstu8iE3G6kqR4Jqj")
176
+ results = semantic_scholar_fetcher.search_by_title("Zero Bubble Pipeline Parallelism")
177
+ print('results: \n', results)
src/parsers/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Parsers package (bib-only)."""
2
+ from .bib_parser import BibParser
3
+
4
+ __all__ = ["BibParser"]
src/parsers/bib_parser.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BibTeX file parser.
3
+ """
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+ from pathlib import Path
8
+
9
+ import bibtexparser
10
+ from bibtexparser.bparser import BibTexParser
11
+ from bibtexparser.customization import convert_to_unicode
12
+
13
+
14
+ @dataclass
15
+ class BibEntry:
16
+ """Represents a parsed bibliography entry."""
17
+ key: str
18
+ entry_type: str
19
+ title: str = ""
20
+ author: str = ""
21
+ year: str = ""
22
+ abstract: str = ""
23
+ url: str = ""
24
+ doi: str = ""
25
+ arxiv_id: str = ""
26
+ journal: str = ""
27
+ booktitle: str = ""
28
+ publisher: str = ""
29
+ pages: str = ""
30
+ volume: str = ""
31
+ number: str = ""
32
+ raw_entry: dict = field(default_factory=dict)
33
+
34
+ @property
35
+ def has_arxiv(self) -> bool:
36
+ """Check if entry has arXiv information."""
37
+ return bool(self.arxiv_id)
38
+
39
+ @property
40
+ def search_query(self) -> str:
41
+ """Get search query for this entry."""
42
+ return self.title or self.key
43
+
44
+
45
+ class BibParser:
46
+ """Parser for .bib files."""
47
+
48
+ # Patterns for extracting arXiv IDs
49
+ ARXIV_PATTERNS = [
50
+ # New format: 2301.00001 or 2301.00001v1
51
+ r'(\d{4}\.\d{4,5}(?:v\d+)?)',
52
+ # Old format: hep-th/9901001 or math.GT/0309136
53
+ r'([a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)',
54
+ # arXiv: prefix
55
+ r'arXiv:(\d{4}\.\d{4,5}(?:v\d+)?)',
56
+ r'arXiv:([a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)',
57
+ ]
58
+
59
+ # URL patterns for arXiv
60
+ ARXIV_URL_PATTERNS = [
61
+ r'arxiv\.org/abs/(\d{4}\.\d{4,5}(?:v\d+)?)',
62
+ r'arxiv\.org/abs/([a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)',
63
+ r'arxiv\.org/pdf/(\d{4}\.\d{4,5}(?:v\d+)?)(?:\.pdf)?',
64
+ r'arxiv\.org/pdf/([a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)(?:\.pdf)?',
65
+ ]
66
+
67
+ def __init__(self):
68
+ self.entries: list[BibEntry] = []
69
+
70
+ def parse_file(self, filepath: str) -> list[BibEntry]:
71
+ """Parse a .bib file into a list of entries."""
72
+ path = Path(filepath)
73
+ if not path.exists():
74
+ raise FileNotFoundError(f"Bib file not found: {filepath}")
75
+
76
+ with open(path, 'r', encoding='utf-8', errors='replace') as f:
77
+ content = f.read()
78
+
79
+ return self.parse_content(content)
80
+
81
+ def parse_content(self, content: str) -> list[BibEntry]:
82
+ """Parse bib content string into a list of entries."""
83
+ parser = BibTexParser(common_strings=True)
84
+ parser.customization = convert_to_unicode
85
+
86
+ try:
87
+ bib_database = bibtexparser.loads(content, parser=parser)
88
+ except Exception as e:
89
+ raise ValueError(f"Failed to parse bib content: {e}")
90
+
91
+ self.entries = []
92
+ for entry in bib_database.entries:
93
+ bib_entry = self._convert_entry(entry)
94
+ self.entries.append(bib_entry)
95
+
96
+ return self.entries
97
+
98
+ def _convert_entry(self, entry: dict) -> BibEntry:
99
+ """Convert a bibtexparser entry to BibEntry."""
100
+ # Extract basic fields
101
+ bib_entry = BibEntry(
102
+ key=entry.get('ID', ''),
103
+ entry_type=entry.get('ENTRYTYPE', ''),
104
+ title=entry.get('title', ''),
105
+ author=entry.get('author', ''),
106
+ year=entry.get('year', ''),
107
+ abstract=entry.get('abstract', ''),
108
+ url=entry.get('url', ''),
109
+ doi=entry.get('doi', ''),
110
+ journal=entry.get('journal', ''),
111
+ booktitle=entry.get('booktitle', ''),
112
+ publisher=entry.get('publisher', ''),
113
+ pages=entry.get('pages', ''),
114
+ volume=entry.get('volume', ''),
115
+ number=entry.get('number', ''),
116
+ raw_entry=entry.copy()
117
+ )
118
+
119
+ # Extract arXiv ID
120
+ bib_entry.arxiv_id = self._extract_arxiv_id(entry)
121
+
122
+ return bib_entry
123
+
124
+ def _extract_arxiv_id(self, entry: dict) -> str:
125
+ """Extract arXiv ID from entry."""
126
+ # Check eprint field first
127
+ eprint = entry.get('eprint', '')
128
+ if eprint:
129
+ arxiv_id = self._parse_arxiv_id(eprint)
130
+ if arxiv_id:
131
+ return arxiv_id
132
+
133
+ # Check arxiv field
134
+ arxiv = entry.get('arxiv', '')
135
+ if arxiv:
136
+ arxiv_id = self._parse_arxiv_id(arxiv)
137
+ if arxiv_id:
138
+ return arxiv_id
139
+
140
+ # Check URL field
141
+ url = entry.get('url', '')
142
+ if url:
143
+ for pattern in self.ARXIV_URL_PATTERNS:
144
+ match = re.search(pattern, url, re.IGNORECASE)
145
+ if match:
146
+ return match.group(1)
147
+
148
+ # Check journal field for "arXiv preprint arXiv:XXXX.XXXXX" format
149
+ journal = entry.get('journal', '')
150
+ if journal and 'arxiv' in journal.lower():
151
+ arxiv_id = self._parse_arxiv_id(journal)
152
+ if arxiv_id:
153
+ return arxiv_id
154
+
155
+ # Check note field
156
+ note = entry.get('note', '')
157
+ if note:
158
+ arxiv_id = self._parse_arxiv_id(note)
159
+ if arxiv_id:
160
+ return arxiv_id
161
+
162
+ return ""
163
+
164
+ def _parse_arxiv_id(self, text: str) -> str:
165
+ """Parse arXiv ID from text."""
166
+ for pattern in self.ARXIV_PATTERNS:
167
+ match = re.search(pattern, text)
168
+ if match:
169
+ return match.group(1)
170
+ return ""
171
+
172
+ def get_entry_by_key(self, key: str) -> Optional[BibEntry]:
173
+ """Get entry by citation key."""
174
+ for entry in self.entries:
175
+ if entry.key == key:
176
+ return entry
177
+ return None
178
+
179
+ def filter_file(self, input_path: str, output_path: str, keys_to_keep: set[str]):
180
+ """
181
+ Create a new bib file containing only specified keys.
182
+ Preserves original formatting, comments, and strings.
183
+ """
184
+ with open(input_path, 'r', encoding='utf-8') as f:
185
+ content = f.read()
186
+
187
+ filtered_content = self._filter_content(content, keys_to_keep)
188
+
189
+ with open(output_path, 'w', encoding='utf-8') as f:
190
+ f.write(filtered_content)
191
+
192
+ def _filter_content(self, content: str, keys_to_keep: set[str]) -> str:
193
+ """Filter content string keeping only specified keys."""
194
+ ranges_to_remove = []
195
+ i = 0
196
+ length = len(content)
197
+
198
+ while i < length:
199
+ if content[i] == '@':
200
+ start = i
201
+ # Find opening brace
202
+ brace_open = content.find('{', i)
203
+ if brace_open == -1:
204
+ i += 1
205
+ continue
206
+
207
+ # Get entry type
208
+ entry_type = content[i+1:brace_open].strip().lower()
209
+
210
+ # Skip comments
211
+ if entry_type == 'comment':
212
+ i = brace_open + 1
213
+ continue
214
+
215
+ # Find matching closing brace to determine entry end
216
+ balance = 1
217
+ j = brace_open + 1
218
+ in_quote = False
219
+
220
+ while j < length and balance > 0:
221
+ char = content[j]
222
+
223
+ # Handle escaped characters
224
+ if char == '\\':
225
+ j += 2
226
+ continue
227
+
228
+ if char == '"':
229
+ in_quote = not in_quote
230
+ elif not in_quote:
231
+ if char == '{':
232
+ balance += 1
233
+ elif char == '}':
234
+ balance -= 1
235
+ j += 1
236
+
237
+ end = j
238
+
239
+ # Extract key (between { and ,)
240
+ # Only for standard entries, not @string or @preamble
241
+ if entry_type not in ('string', 'preamble'):
242
+ # Find comma or end of entry
243
+ # Key is usually the first token after {
244
+ key_part = content[brace_open+1:end]
245
+ comma_pos = key_part.find(',')
246
+
247
+ if comma_pos != -1:
248
+ key = key_part[:comma_pos].strip()
249
+
250
+ # If key is NOT in keep list, mark for removal
251
+ if key not in keys_to_keep:
252
+ ranges_to_remove.append((start, end))
253
+
254
+ i = end
255
+ else:
256
+ i += 1
257
+
258
+ # Reconstruct content
259
+ new_content = []
260
+ last_pos = 0
261
+ for start, end in ranges_to_remove:
262
+ new_content.append(content[last_pos:start])
263
+
264
+ # Clean up whitespace after removed entry
265
+ last_pos = end
266
+ while last_pos < length and content[last_pos] in ' \t\r':
267
+ last_pos += 1
268
+ if last_pos < length and content[last_pos] == '\n':
269
+ last_pos += 1
270
+
271
+ new_content.append(content[last_pos:])
272
+ return "".join(new_content)
273
+
src/report/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Report package (bib-only)."""
2
+ from .generator import ReportGenerator, EntryReport
3
+
4
+ __all__ = ["ReportGenerator", "EntryReport"]
src/report/generator.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Optional, List
4
+ from pathlib import Path
5
+
6
+ from ..parsers.bib_parser import BibEntry
7
+ from ..analyzers.metadata_comparator import ComparisonResult
8
+ from ..analyzers.duplicate_detector import DuplicateGroup
9
+
10
+
11
+ @dataclass
12
+ class EntryReport:
13
+ """Report for a single bib entry (bib-only: entry + comparison)."""
14
+ entry: BibEntry
15
+ comparison: Optional[ComparisonResult]
16
+
17
+
18
+ class ReportGenerator:
19
+ """Generates bibliography-only markdown reports."""
20
+
21
+ def __init__(
22
+ self,
23
+ minimal_verified: bool = False,
24
+ check_preprint_ratio: bool = True,
25
+ preprint_warning_threshold: float = 0.50,
26
+ ):
27
+ self.entries: List[EntryReport] = []
28
+ self.duplicate_groups: Optional[List[DuplicateGroup]] = None
29
+ self.bib_files: List[str] = []
30
+ self.minimal_verified = minimal_verified
31
+ self.check_preprint_ratio = check_preprint_ratio
32
+ self.preprint_warning_threshold = preprint_warning_threshold
33
+
34
+ def add_entry_report(self, report: EntryReport):
35
+ self.entries.append(report)
36
+
37
+ def set_metadata(self, bib_files: str | List[str], tex_files: str | List[str] = None):
38
+ if isinstance(bib_files, str):
39
+ self.bib_files = [bib_files]
40
+ else:
41
+ self.bib_files = list(bib_files) if bib_files else []
42
+
43
+ def set_duplicate_groups(self, groups: List[DuplicateGroup]):
44
+ self.duplicate_groups = groups
45
+
46
+ def _is_verified(self, entry: EntryReport) -> bool:
47
+ return not self._has_issues(entry)
48
+
49
+ def _has_issues(self, entry: EntryReport) -> bool:
50
+ return bool(entry.comparison and entry.comparison.has_issues)
51
+
52
+ def _is_preprint(self, entry: BibEntry) -> bool:
53
+ preprint_keywords = [
54
+ "arxiv", "biorxiv", "medrxiv", "ssrn", "preprint",
55
+ "openreview", "techreport", "technical report", "working paper",
56
+ ]
57
+ if entry.entry_type.lower() in ["techreport", "unpublished", "misc"]:
58
+ text = " ".join([
59
+ entry.journal.lower(), entry.booktitle.lower(),
60
+ entry.publisher.lower(), entry.entry_type.lower(),
61
+ ])
62
+ if any(k in text for k in preprint_keywords):
63
+ return True
64
+ if entry.has_arxiv:
65
+ return True
66
+ venue = " ".join([entry.journal.lower(), entry.booktitle.lower(), entry.publisher.lower()])
67
+ return any(k in venue for k in preprint_keywords)
68
+
69
+ def get_summary_stats(self) -> dict:
70
+ """Return bibliography issue counts only (no LaTeX)."""
71
+ total = len(self.entries)
72
+ title_mismatches = author_mismatches = year_mismatches = unable_to_verify = 0
73
+ for e in self.entries:
74
+ if not e.comparison:
75
+ continue
76
+ if e.comparison.has_issues:
77
+ for issue in e.comparison.issues:
78
+ if "Title mismatch" in issue:
79
+ title_mismatches += 1
80
+ elif "Author mismatch" in issue:
81
+ author_mismatches += 1
82
+ elif "Year mismatch" in issue:
83
+ year_mismatches += 1
84
+ elif "Unable to find" in issue:
85
+ unable_to_verify += 1
86
+
87
+ stats = {}
88
+ if title_mismatches > 0:
89
+ stats["Title Mismatches"] = title_mismatches
90
+ if author_mismatches > 0:
91
+ stats["Author Mismatches"] = author_mismatches
92
+ if year_mismatches > 0:
93
+ stats["Year Mismatches"] = year_mismatches
94
+ if unable_to_verify > 0:
95
+ stats["Unable to Verify"] = unable_to_verify
96
+ if self.duplicate_groups:
97
+ stats["Duplicate Groups"] = len(self.duplicate_groups)
98
+ return stats
99
+
100
+ def _generate_issues_section(self) -> List[str]:
101
+ lines = ["## ⚠️ Critical Issues Detected", ""]
102
+ has_any = False
103
+
104
+ if self.duplicate_groups:
105
+ has_any = True
106
+ lines.append("### 🔄 Duplicate Entries")
107
+ for i, group in enumerate(self.duplicate_groups, 1):
108
+ lines.append(f"#### Group {i} (Similarity: {group.similarity_score:.0%})")
109
+ lines.append(f"**Reason:** {group.reason}")
110
+ lines.append("")
111
+ lines.append("| Key | Title | Year |")
112
+ lines.append("|-----|-------|------|")
113
+ for entry in group.entries:
114
+ lines.append(f"| `{entry.key}` | {entry.title} | {entry.year} |")
115
+ lines.append("")
116
+
117
+ issue_entries = [e for e in self.entries if self._has_issues(e)]
118
+ if issue_entries:
119
+ has_any = True
120
+ lines.append("### ⚠️ Metadata Issues")
121
+ for report in issue_entries:
122
+ lines.extend(self._format_entry_detail(report, is_verified=False))
123
+
124
+ if not has_any:
125
+ lines.append("🎉 **No critical issues found!**")
126
+ return lines
127
+
128
+ def _generate_verified_section(self) -> List[str]:
129
+ lines = ["## ✅ Verified Entries", ""]
130
+ verified = [e for e in self.entries if self._is_verified(e)]
131
+ if not verified:
132
+ lines.append("_No verified entries found._")
133
+ return lines
134
+ lines.append(f"Found **{len(verified)}** entries with correct metadata.")
135
+ lines.append("")
136
+ lines.append("<details>")
137
+ lines.append("<summary>Click to view verified entries</summary>")
138
+ lines.append("")
139
+ for report in verified:
140
+ lines.extend(self._format_entry_detail(report, minimal=self.minimal_verified, is_verified=True))
141
+ lines.append("</details>")
142
+ return lines
143
+
144
+ def _format_entry_detail(self, report: EntryReport, minimal: bool = False, is_verified: bool = False) -> List[str]:
145
+ entry = report.entry
146
+ comp = report.comparison
147
+ lines = []
148
+ icon = "✅" if is_verified else "⚠️"
149
+ lines.append(f"#### {icon} `{entry.key}`")
150
+ lines.append(f"**Title:** {entry.title}")
151
+ lines.append("")
152
+ if comp:
153
+ status_icon = "✅" if comp.is_match else "❌"
154
+ lines.append(f"- **Metadata Status:** {status_icon} {comp.source.upper()} (Confidence: {comp.confidence:.1%})")
155
+ if comp.has_issues and not minimal:
156
+ lines.append(" - **Discrepancies:**")
157
+ for issue in comp.issues:
158
+ if "Mismatch" in issue or "mismatch" in issue:
159
+ lines.append(f" - 🔴 {issue}")
160
+ if "Title" in issue:
161
+ lines.append(f" - **Bib:** `{comp.bib_title}`")
162
+ lines.append(f" - **Fetched:** `{comp.fetched_title}`")
163
+ elif "Author" in issue:
164
+ lines.append(f" - **Bib:** `{', '.join(comp.bib_authors)}`")
165
+ lines.append(f" - **Fetched:** `{', '.join(comp.fetched_authors)}`")
166
+ else:
167
+ lines.append(f" - 🔸 {issue}")
168
+ lines.append("")
169
+ lines.append("---")
170
+ lines.append("")
171
+ return lines
172
+
173
+ def save_bibliography_report(self, filepath: str):
174
+ """Generate and save bibliography-only report."""
175
+ total = len(self.entries)
176
+ verified = sum(1 for e in self.entries if self._is_verified(e))
177
+ issues = sum(1 for e in self.entries if self._has_issues(e))
178
+ dup_str = str(len(self.duplicate_groups)) if self.duplicate_groups else "N/A"
179
+
180
+ preprint_str = "N/A"
181
+ preprint_warning = []
182
+ if self.check_preprint_ratio and self.entries:
183
+ preprint_count = sum(1 for e in self.entries if self._is_preprint(e.entry))
184
+ preprint_ratio = preprint_count / len(self.entries)
185
+ preprint_str = f"{preprint_count} ({preprint_ratio:.1%})"
186
+ if preprint_ratio > self.preprint_warning_threshold:
187
+ preprint_warning = [
188
+ "",
189
+ f"> ⚠️ **High Preprint Ratio:** {preprint_ratio:.1%} of entries are preprints.",
190
+ ]
191
+
192
+ bib_names = ", ".join([f"`{Path(f).name}`" for f in self.bib_files]) if self.bib_files else "N/A"
193
+ lines = [
194
+ "# Bibliography Validation Report",
195
+ "",
196
+ f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
197
+ "",
198
+ "| File Type | Filename |",
199
+ "|-----------|----------|",
200
+ f"| **Bib File(s)** | {bib_names} |",
201
+ "",
202
+ "> **⚠️ Disclaimer:** This report is generated by an automated tool. Please verify reported issues manually.",
203
+ "",
204
+ "## 📊 Summary",
205
+ "",
206
+ "| Metric | Count |",
207
+ "|--------|-------|",
208
+ f"| **Total Entries** | {total} |",
209
+ f"| ✅ **Verified (Clean)** | {verified} |",
210
+ f"| ⚠️ **With Issues** | {issues} |",
211
+ f"| 🔄 **Duplicate Groups** | {dup_str} |",
212
+ f"| 📄 **Preprints** | {preprint_str} |",
213
+ "",
214
+ ]
215
+ if preprint_warning:
216
+ lines.extend(preprint_warning)
217
+ lines.append("")
218
+ lines.extend(self._generate_issues_section())
219
+ lines.append("")
220
+ lines.extend(self._generate_verified_section())
221
+ lines.append("")
222
+ lines.append("---")
223
+ lines.append(f"Report generated by **CiteScan** on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
224
+
225
+ with open(filepath, "w", encoding="utf-8") as f:
226
+ f.write("\n".join(lines))
src/utils/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Utilities package"""
2
+ from .normalizer import TextNormalizer
3
+ from .progress import ProgressDisplay
4
+
5
+ __all__ = ['TextNormalizer', 'ProgressDisplay']
src/utils/fetched_bibtex.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build BibTeX string from fetched metadata (ground truth).
3
+ Single central builder: dispatches by source and formats one consistent style.
4
+ """
5
+ from typing import Any, List, Optional
6
+ import re
7
+
8
+
9
+ def _escape(s: str) -> str:
10
+ """Escape BibTeX special chars: \\ { }"""
11
+ if not s:
12
+ return ""
13
+ return s.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
14
+
15
+
16
+ def _author_list_to_bibtex(authors: Any) -> str:
17
+ """Convert authors (list or str) to BibTeX author field (Name1 and Name2)."""
18
+ if isinstance(authors, str):
19
+ return _escape(authors.strip())
20
+ if isinstance(authors, list):
21
+ return " and ".join(_escape(str(a).strip()) for a in authors if a)
22
+ return ""
23
+
24
+
25
+ def _first_author_last_name(authors: Any) -> str:
26
+ """Get last name (last word) of first author for key generation."""
27
+ if isinstance(authors, str):
28
+ parts = authors.strip().split()
29
+ return parts[-1] if parts else "unknown"
30
+ if isinstance(authors, list) and authors:
31
+ first = str(authors[0]).strip()
32
+ parts = first.split()
33
+ return parts[-1] if parts else "unknown"
34
+ return "unknown"
35
+
36
+
37
+ def _bibtex_key(authors: Any, year: str) -> str:
38
+ """Generate a safe BibTeX key: LastNameYear."""
39
+ last = _first_author_last_name(authors)
40
+ # Alphanumeric only for key
41
+ last = re.sub(r"[^a-zA-Z0-9]", "", last)
42
+ y = (year or "nodate").strip()
43
+ y = re.sub(r"[^0-9]", "", y)[:4] if y else "nodate"
44
+ return f"{last}{y}" if last else f"ref{y}"
45
+
46
+
47
+ def build_fetched_bibtex(source: str, result: Any) -> str:
48
+ """
49
+ Build a BibTeX entry string from fetched metadata.
50
+ source: 'arxiv' | 'crossref' | 'scholar' | 'semantic_scholar' | 'openalex' | 'dblp'
51
+ result: the fetcher result object (ArxivMetadata, CrossRefResult, etc.)
52
+ """
53
+ title = ""
54
+ authors: Any = []
55
+ year = ""
56
+ doi = ""
57
+ url = ""
58
+ venue = ""
59
+ entry_type = "misc"
60
+
61
+ if source == "arxiv":
62
+ title = getattr(result, "title", "") or ""
63
+ authors = getattr(result, "authors", []) or []
64
+ year = getattr(result, "year", "") or "" # property
65
+ doi = getattr(result, "doi", "") or ""
66
+ url = getattr(result, "abs_url", "") or ""
67
+ venue = getattr(result, "journal_ref", "") or ""
68
+ entry_type = "article" if venue else "misc"
69
+ elif source == "crossref":
70
+ title = getattr(result, "title", "") or ""
71
+ authors = getattr(result, "authors", []) or []
72
+ year = getattr(result, "year", "") or ""
73
+ doi = getattr(result, "doi", "") or ""
74
+ url = getattr(result, "url", "") or ""
75
+ venue = getattr(result, "container_title", "") or ""
76
+ entry_type = "article"
77
+ elif source == "scholar":
78
+ title = getattr(result, "title", "") or ""
79
+ authors = getattr(result, "authors", "") or ""
80
+ year = getattr(result, "year", "") or ""
81
+ url = getattr(result, "url", "") or ""
82
+ entry_type = "misc"
83
+ elif source == "semantic_scholar":
84
+ title = getattr(result, "title", "") or ""
85
+ authors = getattr(result, "authors", []) or []
86
+ year = getattr(result, "year", "") or ""
87
+ url = getattr(result, "url", "") or ""
88
+ entry_type = "misc"
89
+ elif source == "openalex":
90
+ title = getattr(result, "title", "") or ""
91
+ authors = getattr(result, "authors", []) or []
92
+ year = getattr(result, "year", "") or ""
93
+ doi = getattr(result, "doi", "") or ""
94
+ url = getattr(result, "url", "") or ""
95
+ entry_type = "misc"
96
+ elif source == "dblp":
97
+ title = getattr(result, "title", "") or ""
98
+ authors = getattr(result, "authors", []) or []
99
+ year = getattr(result, "year", "") or ""
100
+ doi = getattr(result, "doi", "") or ""
101
+ url = getattr(result, "url", "") or ""
102
+ entry_type = "misc"
103
+ else:
104
+ return ""
105
+
106
+ key = _bibtex_key(authors, year)
107
+ author_str = _author_list_to_bibtex(authors)
108
+
109
+ lines = [f" author = {{{author_str}}}", f" title = {{{_escape(title)}}}", f" year = {{{year or '?'}}}"]
110
+ if venue:
111
+ lines.append(f" journal = {{{_escape(venue)}}}")
112
+ if doi:
113
+ lines.append(f" doi = {{{_escape(doi)}}}")
114
+ if url:
115
+ lines.append(f" url = {{{_escape(url)}}}")
116
+ lines.append(f" note = {{Fetched from {source}}}")
117
+
118
+ return f"@{entry_type}{{{key},\n" + ",\n".join(lines) + "\n}"
src/utils/normalizer.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Text normalization utilities for comparing bibliography entries.
3
+ """
4
+ import re
5
+ import unicodedata
6
+ from unidecode import unidecode
7
+
8
+
9
+ class TextNormalizer:
10
+ """Utility class for normalizing text for comparison."""
11
+
12
+ # LaTeX command patterns
13
+ LATEX_COMMANDS = [
14
+ (r'\\textbf\{([^}]*)\}', r'\1'),
15
+ (r'\\textit\{([^}]*)\}', r'\1'),
16
+ (r'\\emph\{([^}]*)\}', r'\1'),
17
+ (r'\\textrm\{([^}]*)\}', r'\1'),
18
+ (r'\\texttt\{([^}]*)\}', r'\1'),
19
+ (r'\\textsf\{([^}]*)\}', r'\1'),
20
+ (r'\\textsc\{([^}]*)\}', r'\1'),
21
+ (r'\\text\{([^}]*)\}', r'\1'),
22
+ (r'\\mathrm\{([^}]*)\}', r'\1'),
23
+ (r'\\mathbf\{([^}]*)\}', r'\1'),
24
+ (r'\\mathit\{([^}]*)\}', r'\1'),
25
+ (r'\\url\{([^}]*)\}', r'\1'),
26
+ (r'\\href\{[^}]*\}\{([^}]*)\}', r'\1'),
27
+ ]
28
+
29
+ # LaTeX special character mappings
30
+ LATEX_CHARS = {
31
+ r'\&': '&',
32
+ r'\%': '%',
33
+ r'\$': '$',
34
+ r'\#': '#',
35
+ r'\_': '_',
36
+ r'\{': '{',
37
+ r'\}': '}',
38
+ r'\~': '~',
39
+ r'\^': '^',
40
+ r'``': '"',
41
+ r"''": '"',
42
+ r'`': "'",
43
+ r"'": "'",
44
+ r'--': '–',
45
+ r'---': '—',
46
+ }
47
+
48
+ # LaTeX accent commands
49
+ LATEX_ACCENTS = [
50
+ (r"\\'([aeiouAEIOU])", r'\1'), # acute
51
+ (r'\\`([aeiouAEIOU])', r'\1'), # grave
52
+ (r'\\^([aeiouAEIOU])', r'\1'), # circumflex
53
+ (r'\\"([aeiouAEIOU])', r'\1'), # umlaut
54
+ (r'\\~([nNaAoO])', r'\1'), # tilde
55
+ (r'\\c\{([cC])\}', r'\1'), # cedilla
56
+ (r"\\'{([aeiouAEIOU])}", r'\1'),
57
+ (r'\\`{([aeiouAEIOU])}', r'\1'),
58
+ (r'\\^{([aeiouAEIOU])}', r'\1'),
59
+ (r'\\"{([aeiouAEIOU])}', r'\1'),
60
+ (r'\\~{([nNaAoO])}', r'\1'),
61
+ ]
62
+
63
+ @classmethod
64
+ def normalize_latex(cls, text: str) -> str:
65
+ """Remove LaTeX formatting commands."""
66
+ if not text:
67
+ return ""
68
+
69
+ result = text
70
+
71
+ # Remove LaTeX commands
72
+ for pattern, replacement in cls.LATEX_COMMANDS:
73
+ result = re.sub(pattern, replacement, result)
74
+
75
+ # Handle LaTeX accents
76
+ for pattern, replacement in cls.LATEX_ACCENTS:
77
+ result = re.sub(pattern, replacement, result)
78
+
79
+ # Replace LaTeX special characters
80
+ for latex_char, normal_char in cls.LATEX_CHARS.items():
81
+ result = result.replace(latex_char, normal_char)
82
+
83
+ # Remove remaining braces
84
+ result = re.sub(r'[{}]', '', result)
85
+
86
+ return result
87
+
88
+ @classmethod
89
+ def normalize_unicode(cls, text: str) -> str:
90
+ """Normalize Unicode characters to ASCII."""
91
+ if not text:
92
+ return ""
93
+
94
+ # Normalize unicode
95
+ text = unicodedata.normalize('NFKD', text)
96
+ # Convert to ASCII
97
+ text = unidecode(text)
98
+ return text
99
+
100
+ @classmethod
101
+ def normalize_whitespace(cls, text: str) -> str:
102
+ """Normalize whitespace."""
103
+ if not text:
104
+ return ""
105
+
106
+ # Replace multiple whitespace with single space
107
+ text = re.sub(r'\s+', ' ', text)
108
+ # Strip leading/trailing whitespace
109
+ text = text.strip()
110
+ return text
111
+
112
+ @classmethod
113
+ def remove_punctuation(cls, text: str) -> str:
114
+ """Remove punctuation for comparison."""
115
+ if not text:
116
+ return ""
117
+
118
+ # Keep alphanumeric and spaces only
119
+ return re.sub(r'[^\w\s]', '', text)
120
+
121
+ @classmethod
122
+ def normalize_for_comparison(cls, text: str) -> str:
123
+ """
124
+ Full normalization pipeline for text comparison.
125
+
126
+ Steps:
127
+ 1. Remove LaTeX formatting
128
+ 2. Normalize Unicode to ASCII
129
+ 3. Convert to lowercase
130
+ 4. Normalize whitespace
131
+ 5. Remove punctuation
132
+ """
133
+ if not text:
134
+ return ""
135
+
136
+ text = cls.normalize_latex(text)
137
+ text = cls.normalize_unicode(text)
138
+ text = text.lower()
139
+ text = cls.normalize_whitespace(text)
140
+ text = cls.remove_punctuation(text)
141
+ return text
142
+
143
+ @classmethod
144
+ def normalize_author_name(cls, name: str) -> str:
145
+ """
146
+ Normalize author name format.
147
+ Handles: "Last, First" and "First Last" formats.
148
+ Returns: normalized "first last" format.
149
+ """
150
+ if not name:
151
+ return ""
152
+
153
+ name = cls.normalize_latex(name)
154
+ name = cls.normalize_unicode(name)
155
+ name = cls.normalize_whitespace(name)
156
+
157
+ # Handle "Last, First" format
158
+ if ',' in name:
159
+ parts = name.split(',', 1)
160
+ if len(parts) == 2:
161
+ name = f"{parts[1].strip()} {parts[0].strip()}"
162
+
163
+ name = name.lower()
164
+ name = cls.remove_punctuation(name)
165
+ return name
166
+
167
+ @classmethod
168
+ def normalize_author_list(cls, authors: str) -> list[str]:
169
+ """
170
+ Parse and normalize a list of authors.
171
+ Handles "and" as separator and "Last, First" format.
172
+ """
173
+ if not authors:
174
+ return []
175
+
176
+ # Split by " and "
177
+ author_list = re.split(r'\s+and\s+', authors, flags=re.IGNORECASE)
178
+
179
+ # Normalize each author
180
+ normalized = []
181
+ for author in author_list:
182
+ normalized_name = cls.normalize_author_name(author.strip())
183
+ if normalized_name:
184
+ normalized.append(normalized_name)
185
+
186
+ return normalized
187
+
188
+ @classmethod
189
+ def similarity_ratio(cls, text1: str, text2: str) -> float:
190
+ """Calculate word-based Jaccard similarity ratio between two strings."""
191
+ if not text1 or not text2:
192
+ return 0.0
193
+
194
+ words1 = set(text1.split())
195
+ words2 = set(text2.split())
196
+
197
+ if not words1 and not words2:
198
+ return 1.0
199
+ if not words1 or not words2:
200
+ return 0.0
201
+
202
+ intersection = words1 & words2
203
+ union = words1 | words2
204
+
205
+ return len(intersection) / len(union)
206
+
207
+ @classmethod
208
+ def levenshtein_similarity(cls, s1: str, s2: str) -> float:
209
+ """Calculate normalized Levenshtein similarity."""
210
+ if not s1 and not s2:
211
+ return 1.0
212
+ if not s1 or not s2:
213
+ return 0.0
214
+
215
+ # Simple Levenshtein implementation
216
+ m, n = len(s1), len(s2)
217
+ dp = [[0] * (n + 1) for _ in range(m + 1)]
218
+
219
+ for i in range(m + 1):
220
+ dp[i][0] = i
221
+ for j in range(n + 1):
222
+ dp[0][j] = j
223
+
224
+ for i in range(1, m + 1):
225
+ for j in range(1, n + 1):
226
+ if s1[i-1] == s2[j-1]:
227
+ dp[i][j] = dp[i-1][j-1]
228
+ else:
229
+ dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
230
+
231
+ max_len = max(m, n)
232
+ distance = dp[m][n]
233
+ return 1.0 - (distance / max_len)
src/utils/progress.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rich.console import Console
2
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+ from rich.live import Live
6
+ from rich.layout import Layout
7
+ from rich.text import Text
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+
13
+ @dataclass
14
+ class ProgressStats:
15
+ """Statistics for progress display."""
16
+ total_entries: int = 0
17
+ processed: int = 0
18
+ success: int = 0
19
+ warnings: int = 0
20
+ errors: int = 0
21
+ current_entry: str = ""
22
+ current_task: str = ""
23
+
24
+
25
+ class ProgressDisplay:
26
+ """Rich terminal progress display."""
27
+
28
+ def __init__(self):
29
+ self.console = Console()
30
+ self.stats = ProgressStats()
31
+ self._progress: Optional[Progress] = None
32
+ self._live: Optional[Live] = None
33
+ self._main_task = None
34
+
35
+ def _create_stats_table(self) -> Table:
36
+ """Create a statistics table."""
37
+ table = Table(show_header=False, box=None, padding=(0, 2))
38
+ table.add_column("Label", style="dim")
39
+ table.add_column("Value", style="bold")
40
+
41
+ table.add_row("📚 Total Entries", str(self.stats.total_entries))
42
+ table.add_row("✅ Success", f"[green]{self.stats.success}[/green]")
43
+ table.add_row("⚠️ Warnings", f"[yellow]{self.stats.warnings}[/yellow]")
44
+ table.add_row("❌ Errors", f"[red]{self.stats.errors}[/red]")
45
+
46
+ return table
47
+
48
+ def _create_display(self) -> Panel:
49
+ """Create the main display panel."""
50
+ layout = Layout()
51
+
52
+ # Status text
53
+ status_text = Text()
54
+ status_text.append("Current: ", style="dim")
55
+ status_text.append(self.stats.current_entry or "N/A", style="cyan bold")
56
+ status_text.append("\n")
57
+ status_text.append("Task: ", style="dim")
58
+ status_text.append(self.stats.current_task or "Initializing...", style="white")
59
+
60
+ return Panel(
61
+ status_text,
62
+ title="[bold blue]📖 Bibliography Checker[/bold blue]",
63
+ border_style="blue"
64
+ )
65
+
66
+ @contextmanager
67
+ def progress_context(self, total: int, description: str = "Processing"):
68
+ """Context manager for progress display."""
69
+ self.stats.total_entries = total
70
+
71
+ with Progress(
72
+ SpinnerColumn(),
73
+ TextColumn("[progress.description]{task.description}"),
74
+ BarColumn(bar_width=40),
75
+ TaskProgressColumn(),
76
+ TimeElapsedColumn(),
77
+ console=self.console,
78
+ transient=False
79
+ ) as progress:
80
+ self._progress = progress
81
+ self._main_task = progress.add_task(description, total=total)
82
+ try:
83
+ yield self
84
+ finally:
85
+ self._progress = None
86
+ self._main_task = None
87
+
88
+ def update(self, entry_key: str = "", task: str = "", advance: int = 0):
89
+ """Update progress display."""
90
+ if entry_key:
91
+ self.stats.current_entry = entry_key
92
+ if task:
93
+ self.stats.current_task = task
94
+
95
+ if self._progress and self._main_task is not None:
96
+ desc = f"[cyan]{entry_key}[/cyan] - {task}" if entry_key else task
97
+ self._progress.update(self._main_task, description=desc, advance=advance)
98
+ self.stats.processed += advance
99
+
100
+ def mark_success(self):
101
+ """Mark current entry as successful."""
102
+ self.stats.success += 1
103
+
104
+ def mark_warning(self):
105
+ """Mark current entry with warning."""
106
+ self.stats.warnings += 1
107
+
108
+ def mark_error(self):
109
+ """Mark current entry as error."""
110
+ self.stats.errors += 1
111
+
112
+ def print_header(self, title: str):
113
+ """Print a section header."""
114
+ self.console.print()
115
+ self.console.print(Panel(
116
+ f"[bold]{title}[/bold]",
117
+ border_style="blue",
118
+ expand=False
119
+ ))
120
+
121
+ def print_status(self, message: str, style: str = ""):
122
+ """Print a status message."""
123
+ self.console.print(f" {message}", style=style)
124
+
125
+ def print_success(self, message: str):
126
+ """Print a success message."""
127
+ self.console.print(f" [green]✓[/green] {message}")
128
+
129
+ def print_warning(self, message: str):
130
+ """Print a warning message."""
131
+ self.console.print(f" [yellow]⚠[/yellow] {message}")
132
+
133
+ def print_error(self, message: str):
134
+ """Print an error message."""
135
+ self.console.print(f" [red]✗[/red] {message}")
136
+
137
+ def print_info(self, message: str):
138
+ """Print an info message."""
139
+ self.console.print(f" [blue]ℹ[/blue] {message}")
140
+
141
+ def print_detailed_summary(self, bib_stats: dict, output_dir: str):
142
+ """Print bibliography issue summary (bib-only)."""
143
+ self.console.print()
144
+ bib_table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
145
+ bib_table.add_column("📚 Bibliography Issues", style="white")
146
+ bib_table.add_column("Count", justify="right", style="bold red")
147
+ if bib_stats:
148
+ for label, value in bib_stats.items():
149
+ bib_table.add_row(label, str(value))
150
+ else:
151
+ bib_table.add_row("[green]No issues found[/green]", "0")
152
+ summary_panel = Panel(
153
+ bib_table,
154
+ title="[bold]📚 Bibliography Summary[/bold]",
155
+ border_style="blue",
156
+ padding=(1, 2),
157
+ )
158
+ self.console.print(summary_panel)
159
+ guide_table = Table(show_header=True, header_style="bold green", box=None, padding=(0, 2))
160
+ guide_table.add_column("File Name", style="cyan")
161
+ guide_table.add_column("Description", style="dim")
162
+ guide_table.add_row("bibliography_report.md", "Metadata verification and issues for each bib entry")
163
+ self.console.print(Panel(
164
+ guide_table,
165
+ title="[bold green]Output Directory[/bold green]",
166
+ subtitle=f"Location: [blue underline]{output_dir}[/blue underline]",
167
+ border_style="green",
168
+ padding=(1, 1),
169
+ ))