aivolcano commited on
Commit ·
350babd
0
Parent(s):
Initial commit
Browse files- .gitignore +62 -0
- .gradio/certificate.pem +31 -0
- README.md +84 -0
- Untitled +1 -0
- app.py +626 -0
- assets/screenshot_classify.png +0 -0
- citescan.yaml +39 -0
- config.yaml +33 -0
- requirements.txt +8 -0
- src/__init__.py +1 -0
- src/analyzers/__init__.py +5 -0
- src/analyzers/duplicate_detector.py +204 -0
- src/analyzers/metadata_comparator.py +502 -0
- src/config/__init__.py +4 -0
- src/config/workflow.py +174 -0
- src/config/yaml_config.py +170 -0
- src/fetchers/__init__.py +16 -0
- src/fetchers/arxiv_fetcher.py +228 -0
- src/fetchers/crossref_fetcher.py +203 -0
- src/fetchers/dblp_fetcher.py +121 -0
- src/fetchers/openalex_fetcher.py +201 -0
- src/fetchers/scholar_fetcher.py +225 -0
- src/fetchers/semantic_scholar_fetcher.py +177 -0
- src/parsers/__init__.py +4 -0
- src/parsers/bib_parser.py +273 -0
- src/report/__init__.py +4 -0
- src/report/generator.py +226 -0
- src/utils/__init__.py +5 -0
- src/utils/fetched_bibtex.py +118 -0
- src/utils/normalizer.py +233 -0
- src/utils/progress.py +169 -0
.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 |
+

|
| 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 |
+

|
| 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("&", "&").replace("<", "<").replace(">", ">")
|
| 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 |
+
))
|