BibexPy 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. bibex_core/MergeDB.py +1189 -0
  2. bibex_core/__init__.py +13 -0
  3. bibex_core/modules/__init__.py +35 -0
  4. bibex_core/modules/api_utils.py +1172 -0
  5. bibex_core/modules/c1_utils.py +423 -0
  6. bibex_core/modules/file_utils.py +71 -0
  7. bibex_core/modules/ml_utils.py +332 -0
  8. bibex_core/modules/post_process.py +50 -0
  9. bibex_core/modules/stats_utils.py +216 -0
  10. bibex_core/scp2xlsx.py +235 -0
  11. bibex_core/wos2xlsx.py +159 -0
  12. bibex_core/xlsx2vos.py +185 -0
  13. bibexpy/__init__.py +9 -0
  14. bibexpy/__main__.py +6 -0
  15. bibexpy/_server/__init__.py +1 -0
  16. bibexpy/_server/config.py +92 -0
  17. bibexpy/_server/jobs/__init__.py +3 -0
  18. bibexpy/_server/jobs/runner.py +301 -0
  19. bibexpy/_server/main.py +211 -0
  20. bibexpy/_server/models/__init__.py +0 -0
  21. bibexpy/_server/models/project.py +25 -0
  22. bibexpy/_server/routers/__init__.py +0 -0
  23. bibexpy/_server/routers/audit.py +65 -0
  24. bibexpy/_server/routers/convert.py +101 -0
  25. bibexpy/_server/routers/disambiguate.py +177 -0
  26. bibexpy/_server/routers/downloads.py +26 -0
  27. bibexpy/_server/routers/enrich.py +42 -0
  28. bibexpy/_server/routers/export.py +116 -0
  29. bibexpy/_server/routers/export_folder.py +97 -0
  30. bibexpy/_server/routers/filter.py +131 -0
  31. bibexpy/_server/routers/jobs.py +72 -0
  32. bibexpy/_server/routers/merge.py +437 -0
  33. bibexpy/_server/routers/prepare.py +241 -0
  34. bibexpy/_server/routers/projects.py +49 -0
  35. bibexpy/_server/routers/quality.py +288 -0
  36. bibexpy/_server/routers/records.py +204 -0
  37. bibexpy/_server/routers/report.py +111 -0
  38. bibexpy/_server/routers/settings.py +383 -0
  39. bibexpy/_server/routers/system.py +209 -0
  40. bibexpy/_server/routers/tools.py +257 -0
  41. bibexpy/_server/routers/upload.py +110 -0
  42. bibexpy/_server/services/__init__.py +0 -0
  43. bibexpy/_server/services/analyses.py +364 -0
  44. bibexpy/_server/services/audit.py +340 -0
  45. bibexpy/_server/services/bibex_adapter.py +47 -0
  46. bibexpy/_server/services/bibtex_writer.py +104 -0
  47. bibexpy/_server/services/converter.py +205 -0
  48. bibexpy/_server/services/disambiguation/__init__.py +19 -0
  49. bibexpy/_server/services/disambiguation/blocking.py +581 -0
  50. bibexpy/_server/services/disambiguation/cache.py +47 -0
  51. bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
  52. bibexpy/_server/services/disambiguation/orcid.py +261 -0
  53. bibexpy/_server/services/disambiguation/pipeline.py +985 -0
  54. bibexpy/_server/services/disambiguation/similarity.py +91 -0
  55. bibexpy/_server/services/enricher.py +338 -0
  56. bibexpy/_server/services/exporter.py +112 -0
  57. bibexpy/_server/services/filter_engine.py +300 -0
  58. bibexpy/_server/services/merger.py +202 -0
  59. bibexpy/_server/services/methodology.py +102 -0
  60. bibexpy/_server/services/report_export.py +300 -0
  61. bibexpy/_server/services/ris_writer.py +62 -0
  62. bibexpy/_server/services/smart_merger.py +967 -0
  63. bibexpy/_server/services/storage.py +165 -0
  64. bibexpy/_web/404/index.html +1 -0
  65. bibexpy/_web/404.html +1 -0
  66. bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
  67. bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
  68. bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
  69. bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
  70. bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
  71. bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
  72. bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
  73. bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
  74. bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
  75. bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
  76. bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
  77. bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
  78. bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
  79. bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
  80. bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
  81. bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
  82. bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
  83. bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
  84. bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
  85. bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
  86. bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
  87. bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
  88. bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
  89. bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
  90. bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
  91. bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
  92. bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
  93. bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
  94. bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  95. bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
  96. bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
  97. bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  98. bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  99. bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  100. bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
  101. bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
  102. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
  103. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
  104. bibexpy/_web/images/PROMPTS.md +83 -0
  105. bibexpy/_web/images/about-illustration.png +0 -0
  106. bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
  107. bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
  108. bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
  109. bibexpy/_web/images/bibexpy-logo-full.png +0 -0
  110. bibexpy/_web/images/bibexpy-logo-header.png +0 -0
  111. bibexpy/_web/images/bibexpy-logo-in.png +0 -0
  112. bibexpy/_web/images/bibexpy-logo.webp +0 -0
  113. bibexpy/_web/images/hero-illustration.png +0 -0
  114. bibexpy/_web/images/loader.gif +0 -0
  115. bibexpy/_web/images/workflow-diagram.png +0 -0
  116. bibexpy/_web/index.html +1 -0
  117. bibexpy/_web/index.txt +11 -0
  118. bibexpy/_web/projects/_/convert/index.html +1 -0
  119. bibexpy/_web/projects/_/convert/index.txt +12 -0
  120. bibexpy/_web/projects/_/disambiguate/index.html +1 -0
  121. bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
  122. bibexpy/_web/projects/_/enrich/index.html +1 -0
  123. bibexpy/_web/projects/_/enrich/index.txt +12 -0
  124. bibexpy/_web/projects/_/export/index.html +1 -0
  125. bibexpy/_web/projects/_/export/index.txt +12 -0
  126. bibexpy/_web/projects/_/merge/index.html +1 -0
  127. bibexpy/_web/projects/_/merge/index.txt +12 -0
  128. bibexpy/_web/projects/_/records/index.html +1 -0
  129. bibexpy/_web/projects/_/records/index.txt +12 -0
  130. bibexpy/_web/projects/_/report/index.html +1 -0
  131. bibexpy/_web/projects/_/report/index.txt +12 -0
  132. bibexpy/_web/projects/_/upload/index.html +1 -0
  133. bibexpy/_web/projects/_/upload/index.txt +12 -0
  134. bibexpy/_web/projects/index.html +1 -0
  135. bibexpy/_web/projects/index.txt +11 -0
  136. bibexpy/_web/settings/index.html +1 -0
  137. bibexpy/_web/settings/index.txt +11 -0
  138. bibexpy/_web/tools/README.md +65 -0
  139. bibexpy/_web/tools/bibexcel.svg +6 -0
  140. bibexpy/_web/tools/bibliometrix.png +0 -0
  141. bibexpy/_web/tools/biblioshiny.svg +12 -0
  142. bibexpy/_web/tools/citavi.svg +6 -0
  143. bibexpy/_web/tools/citespace.svg +7 -0
  144. bibexpy/_web/tools/citnetexplorer.svg +8 -0
  145. bibexpy/_web/tools/endnote.svg +6 -0
  146. bibexpy/_web/tools/excel.svg +1 -0
  147. bibexpy/_web/tools/gephi.svg +66 -0
  148. bibexpy/_web/tools/histcite.svg +7 -0
  149. bibexpy/_web/tools/index.html +1 -0
  150. bibexpy/_web/tools/index.txt +11 -0
  151. bibexpy/_web/tools/jabref.svg +122 -0
  152. bibexpy/_web/tools/latex.svg +1 -0
  153. bibexpy/_web/tools/mendeley.svg +1 -0
  154. bibexpy/_web/tools/openrefine.svg +15 -0
  155. bibexpy/_web/tools/overleaf.svg +1 -0
  156. bibexpy/_web/tools/papers.svg +7 -0
  157. bibexpy/_web/tools/powerbi.svg +1 -0
  158. bibexpy/_web/tools/python.svg +1 -0
  159. bibexpy/_web/tools/r.svg +1 -0
  160. bibexpy/_web/tools/refworks.svg +5 -0
  161. bibexpy/_web/tools/scite.svg +5 -0
  162. bibexpy/_web/tools/tableau.svg +1 -0
  163. bibexpy/_web/tools/vosviewer.png +0 -0
  164. bibexpy/_web/tools/zotero.svg +1 -0
  165. bibexpy/cli.py +242 -0
  166. bibexpy-2.0.0.dist-info/METADATA +105 -0
  167. bibexpy-2.0.0.dist-info/RECORD +171 -0
  168. bibexpy-2.0.0.dist-info/WHEEL +5 -0
  169. bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
  170. bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
  171. bibexpy-2.0.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1172 @@
1
+ import requests
2
+ import pandas as pd
3
+ import os
4
+ import re
5
+ import json
6
+ import time
7
+ import difflib
8
+ from functools import lru_cache
9
+ from dotenv import load_dotenv
10
+
11
+ # Load .env file
12
+ load_dotenv()
13
+
14
+ # Tüm dış API çağrıları için (connect, read) timeout — tek bir API takılırsa
15
+ # tüm enrichment işinin süresiz kilitlenmesini önler.
16
+ REQUEST_TIMEOUT = (5, 20)
17
+
18
+
19
+ def _get_with_retry(url: str, *, headers: dict | None = None, attempts: int = 3, **kw):
20
+ """requests.get + zorunlu timeout + 429/503 üstel backoff (Retry-After saygılı)."""
21
+ last = None
22
+ for i in range(attempts):
23
+ try:
24
+ r = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT, **kw)
25
+ except requests.RequestException as e:
26
+ last = e
27
+ time.sleep(min(2 ** i, 8))
28
+ continue
29
+ if r.status_code in (429, 503):
30
+ retry_after = r.headers.get("Retry-After")
31
+ try:
32
+ delay = float(retry_after) if retry_after else min(2 ** i, 8)
33
+ except (TypeError, ValueError):
34
+ delay = min(2 ** i, 8)
35
+ time.sleep(min(delay, 15))
36
+ last = r
37
+ continue
38
+ return r
39
+ if isinstance(last, requests.Response):
40
+ return last
41
+ raise last if last else requests.RequestException(f"request failed: {url}")
42
+
43
+
44
+ # ── Ters DOI arama (eksik DI'yi başlık+yıl+yazardan bul) ────────────────────────
45
+ def _norm_title(s) -> str:
46
+ s = re.sub(r"[^a-z0-9 ]", " ", str(s).lower())
47
+ return re.sub(r"\s+", " ", s).strip()
48
+
49
+
50
+ def _title_sim(a, b) -> float:
51
+ na, nb = _norm_title(a), _norm_title(b)
52
+ if not na or not nb:
53
+ return 0.0
54
+ return difflib.SequenceMatcher(None, na, nb).ratio()
55
+
56
+
57
+ def _first_surname(authors) -> str:
58
+ """'ZHANG C; GUO R' / 'Zhang, Chen; ...' -> ilk yazarın soyadı (küçük harf)."""
59
+ if not authors:
60
+ return ""
61
+ first = str(authors).split(";")[0].strip()
62
+ first = first.split(",")[0].strip()
63
+ parts = first.split()
64
+ return (parts[0] if parts else "").lower()
65
+
66
+
67
+ def _verify_candidate(c_title, c_year, c_surname, title, year, surname) -> bool:
68
+ """Dengeli ama KESİN doğrulama: başlık benzerliği >= 0.90, yıl ±1, ilk yazar soyadı.
69
+
70
+ Kesinlik (precision) > geri-çağırma: emin olunmayan aday REDDEDİLİR (yanlış DOI
71
+ yazmaktansa boş bırak).
72
+ • Yıl: ikisi de varsa ve parse edilebiliyorsa ±1; parse edilemezse aday REDDEDİLİR
73
+ (sessizce atlanmaz — eski hata: except: pass yılı tamamen devre dışı bırakıyordu).
74
+ • Soyad: ikisi de varsa TAM eşleşmeli (substring değil — 'li' artık 'oliveira'yı
75
+ kabul etmez).
76
+ """
77
+ if _title_sim(c_title, title) < 0.90:
78
+ return False
79
+ if year and c_year:
80
+ try:
81
+ if abs(int(float(str(c_year))) - int(float(str(year)))) > 1:
82
+ return False
83
+ except (TypeError, ValueError):
84
+ return False # aday yılı yorumlanamıyor → güvenli tarafta kal, reddet
85
+ if surname and c_surname and surname != c_surname:
86
+ return False # tam eşleşme şart (substring kabul edilmez)
87
+ return True
88
+
89
+
90
+ def _crossref_candidates(title: str, crossref_email: str) -> list:
91
+ out = []
92
+ try:
93
+ params = {"query.bibliographic": title, "rows": 5}
94
+ if crossref_email:
95
+ params["mailto"] = crossref_email
96
+ r = _get_with_retry("https://api.crossref.org/works", params=params)
97
+ if r.ok:
98
+ for it in r.json().get("message", {}).get("items", []):
99
+ ti = (it.get("title") or [""])[0]
100
+ issued = (it.get("issued") or {}).get("date-parts") or [[None]]
101
+ cy = issued[0][0] if issued and issued[0] else None
102
+ au = it.get("author") or []
103
+ csur = (au[0].get("family", "") if au else "").lower()
104
+ out.append((it.get("DOI", ""), ti, cy, csur))
105
+ except Exception:
106
+ pass
107
+ return out
108
+
109
+
110
+ def _openalex_candidates(title: str, year: str, crossref_email: str) -> list:
111
+ out = []
112
+ try:
113
+ flt = f"title.search:{title}"
114
+ if year:
115
+ flt += f",publication_year:{year}"
116
+ params = {"filter": flt, "per_page": 5}
117
+ if crossref_email:
118
+ params["mailto"] = crossref_email
119
+ r = _get_with_retry("https://api.openalex.org/works", params=params)
120
+ if r.ok:
121
+ for w in r.json().get("results", []):
122
+ doi = (w.get("doi") or "").replace("https://doi.org/", "")
123
+ ti = w.get("display_name") or w.get("title") or ""
124
+ cy = w.get("publication_year")
125
+ auth = w.get("authorships") or []
126
+ nm = ((auth[0].get("author") or {}).get("display_name", "") if auth else "")
127
+ csur = nm.split()[-1].lower() if nm else ""
128
+ out.append((doi, ti, cy, csur))
129
+ except Exception:
130
+ pass
131
+ return out
132
+
133
+
134
+ _DOI_LOOKUP_CACHE: dict = {}
135
+
136
+
137
+ def _resolve_doi_cached(title: str, year: str, surname: str, crossref_email: str) -> str:
138
+ key = (title, year, surname, crossref_email)
139
+ if key in _DOI_LOOKUP_CACHE:
140
+ return _DOI_LOOKUP_CACHE[key]
141
+ cands = _crossref_candidates(title, crossref_email) + _openalex_candidates(title, year, crossref_email)
142
+ best_doi, best_sim = "", 0.0
143
+ for doi, c_title, c_year, c_surname in cands:
144
+ if not doi:
145
+ continue
146
+ if not _verify_candidate(c_title, c_year, c_surname, title, year, surname):
147
+ continue
148
+ sim = _title_sim(c_title, title)
149
+ if sim > best_sim:
150
+ best_sim, best_doi = sim, doi
151
+ # YALNIZ başarılı sonucu cache'le — geçici ağ hatası "boş" olarak kalıcı yapışmasın.
152
+ if best_doi:
153
+ _DOI_LOOKUP_CACHE[key] = best_doi
154
+ return best_doi
155
+
156
+
157
+ def resolve_doi(title, authors=None, year=None, *, crossref_email: str | None = None):
158
+ """Eksik DOI'yi başlık+yıl+yazardan ters arar (CrossRef -> OpenAlex), doğrular, döndürür.
159
+
160
+ Dengeli eşik: başlık ~0.90, yıl ±1, (varsa) ilk yazar soyadı. Emin değilse None
161
+ döner (asla uydurmaz). Sonuç (title, year, surname, email) ile cache'lenir.
162
+ """
163
+ if not title or not str(title).strip():
164
+ return None
165
+ surname = _first_surname(authors)
166
+ yr = ""
167
+ if year not in (None, ""):
168
+ try:
169
+ yr = str(int(float(str(year))))
170
+ except (TypeError, ValueError):
171
+ yr = ""
172
+ doi = _resolve_doi_cached(str(title).strip()[:300], yr, surname, crossref_email or "")
173
+ return doi or None
174
+
175
+
176
+ def _decode_inverted_abstract(inv: dict) -> str:
177
+ """OpenAlex abstract_inverted_index ({token: [pozisyonlar]}) → düz metin."""
178
+ if not isinstance(inv, dict) or not inv:
179
+ return ""
180
+ positions: list[tuple[int, str]] = []
181
+ for token, idxs in inv.items():
182
+ if isinstance(idxs, list):
183
+ for p in idxs:
184
+ positions.append((int(p), token))
185
+ positions.sort(key=lambda x: x[0])
186
+ return " ".join(tok for _, tok in positions)
187
+
188
+ def load_api_config() -> dict:
189
+ """Load API configuration from API_config.json.
190
+
191
+ Once BIBEXPY_CONFIG_DIR (paket modu, orn. ~/.bibexpy), sonra kaynak-agaci
192
+ konumuna bakar. Bulunamazsa sessizce bos dict doner — v2 sunucusunda API
193
+ anahtarlari env/config.py'den gelir, bu dosya zorunlu degildir.
194
+ """
195
+ candidates = []
196
+ cfg_dir = os.environ.get("BIBEXPY_CONFIG_DIR")
197
+ if cfg_dir:
198
+ candidates.append(os.path.join(os.path.expanduser(cfg_dir), 'API_config.json'))
199
+ candidates.append(
200
+ os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'API_config.json')
201
+ )
202
+ config_path = next((p for p in candidates if os.path.exists(p)), None)
203
+ if not config_path:
204
+ return {}
205
+ try:
206
+ with open(config_path, 'r', encoding='utf-8') as f:
207
+ return json.load(f)
208
+ except Exception as e:
209
+ print(f"\nError loading API configuration: {str(e)}")
210
+ return {}
211
+
212
+ def get_api_credential(service: str, field: str = 'api_key') -> str:
213
+ """Get API credential for a specific service"""
214
+ config = load_api_config()
215
+ if service in config and field in config[service]:
216
+ return config[service][field]
217
+ return None
218
+
219
+ @lru_cache(maxsize=4096)
220
+ def extract_metadata_from_crossref(doi: str, email: str = None) -> dict:
221
+ """Extract metadata from CrossRef API (Free, no key required)"""
222
+ try:
223
+ url = f"https://api.crossref.org/works/{doi}"
224
+ headers = {
225
+ 'User-Agent': f'BibexPy/2.0 (mailto:{email or "info@bibexpy.org"})'
226
+ }
227
+ response = _get_with_retry(url, headers=headers)
228
+
229
+ if response.status_code == 200:
230
+ data = response.json()
231
+ work = data['message']
232
+
233
+ # Extract relevant fields
234
+ metadata = {}
235
+
236
+ # DOI
237
+ metadata['DI'] = work.get('DOI', '')
238
+
239
+ # Document Type
240
+ if 'type' in work:
241
+ metadata['DT'] = work['type']
242
+
243
+ # Authors (+ ORCID)
244
+ if 'author' in work:
245
+ authors = [f"{a.get('given', '')} {a.get('family', '')}".strip() for a in work['author']]
246
+ metadata['AU'] = '; '.join(authors)
247
+ # ORCID (OI) — CrossRef'te çoğu zaman boş ama varsa kesin sinyal.
248
+ orcids = []
249
+ for a in work['author']:
250
+ oid = str(a.get('ORCID') or "").replace("https://orcid.org/", "").replace("http://orcid.org/", "").strip()
251
+ if oid:
252
+ nm = f"{a.get('given', '')} {a.get('family', '')}".strip()
253
+ orcids.append(f"{nm}/{oid}")
254
+ if orcids:
255
+ metadata['OI'] = '; '.join(orcids)
256
+
257
+ # Title
258
+ if 'title' in work:
259
+ metadata['TI'] = work['title'][0]
260
+
261
+ # Publication Year
262
+ if 'issued' in work and 'date-parts' in work['issued']:
263
+ metadata['PY'] = work['issued']['date-parts'][0][0]
264
+
265
+ # Journal/Container Title
266
+ if 'container-title' in work:
267
+ metadata['SO'] = work['container-title'][0]
268
+
269
+ # Publisher
270
+ if 'publisher' in work:
271
+ metadata['PU'] = work['publisher']
272
+
273
+ # Publisher Address/Location
274
+ if 'publisher-location' in work:
275
+ metadata['PA'] = work['publisher-location']
276
+
277
+ # Abstract
278
+ if 'abstract' in work:
279
+ metadata['AB'] = work['abstract']
280
+
281
+ # ISSN
282
+ if 'ISSN' in work:
283
+ metadata['SN'] = '; '.join(work['ISSN'])
284
+
285
+ # URL
286
+ if 'URL' in work:
287
+ metadata['UR'] = work['URL']
288
+
289
+ # Subject -> yalnız anahtar kelime (DE). WC/SC gerçek WoS/Scopus
290
+ # kategorileri DEĞİLDİR; onları ML enrichment doldurur (de-collapse).
291
+ if 'subject' in work and work['subject']:
292
+ metadata['DE'] = '; '.join(work['subject'])
293
+
294
+ # License
295
+ if 'license' in work and work['license']:
296
+ license_urls = [lic.get('URL', '') for lic in work['license']]
297
+ metadata['LI'] = '; '.join(filter(None, license_urls))
298
+
299
+ return metadata
300
+ except Exception as e:
301
+ print(f"CrossRef API Error: {str(e)}")
302
+ return {}
303
+
304
+ def truncate_url(url: str, max_length: int = 2079) -> str:
305
+ """Excel'in URL karakter limitine uygun olarak URL'yi kısalt"""
306
+ if not url or len(url) <= max_length:
307
+ return url
308
+ return url[:max_length-3] + "..."
309
+
310
+ @lru_cache(maxsize=4096)
311
+ def extract_metadata_from_openalex(doi: str, email: str = None) -> dict:
312
+ """Extract metadata from OpenAlex API (Free, no key required)"""
313
+ try:
314
+ url = f"https://api.openalex.org/works/doi:{doi}"
315
+ if email:
316
+ url += f"?mailto={email}"
317
+ response = _get_with_retry(url)
318
+
319
+ if response.status_code == 200:
320
+ work = response.json()
321
+
322
+ # Extract relevant fields
323
+ metadata = {}
324
+
325
+ # DOI
326
+ if 'doi' in work:
327
+ metadata['DI'] = work['doi']
328
+
329
+ # Document Type
330
+ if 'type' in work:
331
+ metadata['DT'] = work['type']
332
+
333
+ # Title
334
+ if 'title' in work:
335
+ metadata['TI'] = work['title']
336
+ elif 'display_name' in work:
337
+ metadata['TI'] = work['display_name']
338
+
339
+ # Authors and Institutions
340
+ if 'authorships' in work:
341
+ # Authors
342
+ authors = []
343
+ institutions = []
344
+ author_institutions = [] # For author-institution pairs
345
+ orcids = [] # WoS OI: 'Author/0000-0000-0000-0000'
346
+ researcher_ids = [] # WoS RI: 'Author/<OpenAlex A-ID>' (yedek kimlik)
347
+ rors = [] # benzersiz ROR kurum URI'leri
348
+ countries = [] # ISO ülke kodları (yazar kurumlarından)
349
+
350
+ for authorship in work['authorships']:
351
+ if 'author' in authorship and 'display_name' in authorship['author']:
352
+ author = authorship['author']
353
+ author_name = author['display_name']
354
+ authors.append(author_name)
355
+
356
+ # ORCID (OI) — disambiguation'ın altın standardı
357
+ orcid = author.get('orcid')
358
+ if orcid:
359
+ oid = str(orcid).replace("https://orcid.org/", "").strip()
360
+ if oid:
361
+ orcids.append(f"{author_name}/{oid}")
362
+ # Yazar kimliği (RI yedek) — OpenAlex A-ID
363
+ aid = author.get('id')
364
+ if aid:
365
+ researcher_ids.append(f"{author_name}/{str(aid).replace('https://openalex.org/', '').strip()}")
366
+
367
+ # Get institutions for this author (+ ROR + country)
368
+ if 'institutions' in authorship:
369
+ author_insts = []
370
+ for inst in authorship['institutions']:
371
+ if 'display_name' in inst:
372
+ inst_name = inst['display_name']
373
+ institutions.append(inst_name)
374
+ author_insts.append(inst_name)
375
+ if inst.get('ror'):
376
+ rors.append(str(inst['ror']).replace("https://ror.org/", "").strip())
377
+ if inst.get('country_code'):
378
+ countries.append(str(inst['country_code']).strip().upper())
379
+
380
+ if author_insts:
381
+ author_institutions.append(f"{author_name} [{'; '.join(author_insts)}]")
382
+ # Yazar-seviyesi countries[] (kurum yoksa da gelebilir)
383
+ for cc in authorship.get('countries', []) or []:
384
+ if cc:
385
+ countries.append(str(cc).strip().upper())
386
+
387
+ if authors:
388
+ metadata['AU'] = '; '.join(authors)
389
+ # Unique institutions
390
+ if institutions:
391
+ metadata['C1'] = '; '.join(list(dict.fromkeys(institutions)))
392
+ # Author-Institution pairs
393
+ if author_institutions:
394
+ metadata['AF'] = '; '.join(author_institutions)
395
+ # ORCID (OI) + ResearcherID (RI) + ROR + ülke kodları
396
+ if orcids:
397
+ metadata['OI'] = '; '.join(orcids)
398
+ if researcher_ids:
399
+ metadata['RI'] = '; '.join(researcher_ids)
400
+ if rors:
401
+ metadata['ROR'] = '; '.join(dict.fromkeys(rors))
402
+ if countries:
403
+ metadata['CC'] = '; '.join(dict.fromkeys(countries))
404
+
405
+ # Publication Year
406
+ if 'publication_year' in work:
407
+ metadata['PY'] = work['publication_year']
408
+
409
+ # Journal/Publisher — yeni şema primary_location.source (host_venue deprecated).
410
+ venue = {}
411
+ ploc = work.get('primary_location') or {}
412
+ if isinstance(ploc, dict) and isinstance(ploc.get('source'), dict):
413
+ venue = ploc['source']
414
+ elif isinstance(work.get('host_venue'), dict):
415
+ venue = work['host_venue']
416
+ if venue:
417
+ if venue.get('display_name'):
418
+ metadata['SO'] = venue['display_name']
419
+ if venue.get('host_organization_name') or venue.get('publisher'):
420
+ metadata['PU'] = venue.get('host_organization_name') or venue.get('publisher')
421
+ if venue.get('issn_l'):
422
+ metadata['SN'] = venue['issn_l']
423
+ if isinstance(ploc, dict) and ploc.get('landing_page_url'):
424
+ metadata['UR'] = truncate_url(ploc['landing_page_url'])
425
+
426
+ # Abstract — OpenAlex 'abstract_inverted_index' verir; düz 'abstract' yok.
427
+ if work.get('abstract'):
428
+ metadata['AB'] = work['abstract']
429
+ elif work.get('abstract_inverted_index'):
430
+ ab = _decode_inverted_abstract(work['abstract_inverted_index'])
431
+ if ab:
432
+ metadata['AB'] = ab
433
+
434
+ # Concepts -> DE (anahtar kelime).
435
+ if 'concepts' in work:
436
+ concepts = [c['display_name'] for c in work['concepts'] if 'display_name' in c]
437
+ if concepts:
438
+ metadata['DE'] = '; '.join(concepts)
439
+
440
+ # Topics -> WC (subfield, daha özel) + SC (field, daha geniş). OpenAlex konu
441
+ # sınıflandırması; WoS taksonomisiyle birebir değil ama açık/şeffaf. WC ve SC
442
+ # ikisi de boş olan kayıtlarda devreye girer (first-non-empty-wins).
443
+ topics = work.get('topics') or ([work['primary_topic']] if work.get('primary_topic') else [])
444
+ if topics:
445
+ subfields, fields = [], []
446
+ for tp in topics:
447
+ if not isinstance(tp, dict):
448
+ continue
449
+ sf = (tp.get('subfield') or {}).get('display_name')
450
+ fd = (tp.get('field') or {}).get('display_name')
451
+ if sf:
452
+ subfields.append(sf)
453
+ if fd:
454
+ fields.append(fd)
455
+ if subfields:
456
+ metadata['WC'] = '; '.join(dict.fromkeys(subfields))
457
+ if fields:
458
+ metadata['SC'] = '; '.join(dict.fromkeys(fields))
459
+
460
+ # Citations
461
+ if 'cited_by_count' in work:
462
+ metadata['TC'] = work['cited_by_count']
463
+
464
+ # Open Access Information
465
+ if 'open_access' in work:
466
+ oa_info = work['open_access']
467
+ oa_details = []
468
+ if 'is_oa' in oa_info:
469
+ oa_details.append(f"is_oa: {oa_info['is_oa']}")
470
+ if 'oa_status' in oa_info:
471
+ oa_details.append(f"status: {oa_info['oa_status']}")
472
+ if 'oa_url' in oa_info:
473
+ oa_details.append(f"url: {oa_info['oa_url']}")
474
+ if oa_details:
475
+ metadata['OA'] = '; '.join(oa_details)
476
+
477
+ # Referenced Works — tümünü birleştir (Excel limiti için kısalt)
478
+ if work.get('referenced_works'):
479
+ metadata['CR'] = truncate_url_list('; '.join(work['referenced_works']))
480
+
481
+ return metadata
482
+ except Exception as e:
483
+ print(f"OpenAlex API Error: {str(e)}")
484
+ return {}
485
+
486
+ @lru_cache(maxsize=4096)
487
+ def extract_metadata_from_scopus(doi: str, api_key: str) -> dict:
488
+ """Extract metadata from Scopus API (Requires API key)"""
489
+ try:
490
+ url = f"https://api.elsevier.com/content/abstract/doi/{doi}"
491
+ headers = {
492
+ 'X-ELS-APIKey': api_key,
493
+ 'Accept': 'application/json'
494
+ }
495
+ response = _get_with_retry(url, headers=headers)
496
+
497
+ if response.status_code == 200:
498
+ data = response.json()
499
+ if 'abstracts-retrieval-response' in data:
500
+ work = data['abstracts-retrieval-response']
501
+ metadata = {}
502
+
503
+ # Coredata'yı kontrol et
504
+ if 'coredata' in work:
505
+ coredata = work['coredata']
506
+
507
+ # DOI
508
+ if 'prism:doi' in coredata:
509
+ metadata['DI'] = coredata['prism:doi']
510
+ elif 'dc:identifier' in coredata:
511
+ metadata['DI'] = coredata['dc:identifier']
512
+
513
+ # Document Type
514
+ if 'prism:aggregationType' in coredata:
515
+ metadata['DT'] = coredata['prism:aggregationType']
516
+
517
+ # Title
518
+ if 'dc:title' in coredata:
519
+ metadata['TI'] = coredata['dc:title']
520
+
521
+ # Journal Name
522
+ if 'prism:publicationName' in coredata:
523
+ metadata['SO'] = coredata['prism:publicationName']
524
+
525
+ # Publisher
526
+ if 'dc:publisher' in coredata:
527
+ metadata['PU'] = coredata['dc:publisher']
528
+
529
+ # Volume
530
+ if 'prism:volume' in coredata:
531
+ metadata['VL'] = coredata['prism:volume']
532
+
533
+ # Issue
534
+ if 'prism:issueIdentifier' in coredata:
535
+ metadata['IS'] = coredata['prism:issueIdentifier']
536
+
537
+ # Page Numbers
538
+ if 'prism:startingPage' in coredata:
539
+ metadata['BP'] = coredata['prism:startingPage']
540
+ if 'prism:endingPage' in coredata:
541
+ metadata['EP'] = coredata['prism:endingPage']
542
+
543
+ # Publication Year
544
+ if 'prism:coverDate' in coredata:
545
+ metadata['PY'] = coredata['prism:coverDate'][:4]
546
+
547
+ # ISSN
548
+ if 'prism:issn' in coredata:
549
+ metadata['SN'] = coredata['prism:issn']
550
+
551
+ # Abstract
552
+ if 'dc:description' in coredata:
553
+ metadata['AB'] = coredata['dc:description']
554
+
555
+ # URLs
556
+ if 'link' in coredata:
557
+ urls = []
558
+ for link in coredata['link']:
559
+ if '@href' in link:
560
+ urls.append(link['@href'])
561
+ if urls:
562
+ metadata['UR'] = '; '.join(urls)
563
+
564
+ # Citation Count
565
+ if 'citedby-count' in coredata:
566
+ metadata['TC'] = coredata['citedby-count']
567
+
568
+ # Authors ve Affiliations
569
+ if 'authors' in work:
570
+ authors = []
571
+ affiliations = []
572
+ author_affiliations = []
573
+ orcids = [] # WoS OI
574
+
575
+ if 'author' in work['authors']:
576
+ for author in work['authors']['author']:
577
+ author_name = []
578
+ if 'ce:given-name' in author:
579
+ author_name.append(author['ce:given-name'])
580
+ if 'ce:surname' in author:
581
+ author_name.append(author['ce:surname'])
582
+
583
+ if author_name:
584
+ full_name = ' '.join(author_name)
585
+ authors.append(full_name)
586
+ # ORCID (Scopus '@orcid')
587
+ oid = str(author.get('@orcid') or "").strip()
588
+ if oid:
589
+ orcids.append(f"{full_name}/{oid}")
590
+
591
+ # Yazar affiliations
592
+ if 'affiliation' in author:
593
+ author_affs = []
594
+ if isinstance(author['affiliation'], list):
595
+ for aff in author['affiliation']:
596
+ if 'affilname' in aff:
597
+ affiliations.append(aff['affilname'])
598
+ author_affs.append(aff['affilname'])
599
+ else:
600
+ aff = author['affiliation']
601
+ if 'affilname' in aff:
602
+ affiliations.append(aff['affilname'])
603
+ author_affs.append(aff['affilname'])
604
+
605
+ if author_affs:
606
+ author_affiliations.append(f"{full_name} [{'; '.join(author_affs)}]")
607
+
608
+ if authors:
609
+ metadata['AU'] = '; '.join(authors)
610
+ if affiliations:
611
+ metadata['C1'] = '; '.join(list(dict.fromkeys(affiliations)))
612
+ if author_affiliations:
613
+ metadata['AF'] = '; '.join(author_affiliations)
614
+ if orcids:
615
+ metadata['OI'] = '; '.join(orcids)
616
+
617
+ # Subject Areas (ASJC) -> DE + WC/SC. Yalnız Scopus key aktifse buraya gelinir.
618
+ # NOT: tek alan olduğunda Elsevier 'subject-area'yı liste yerine TEK dict döndürür.
619
+ sa = (work.get('subject-areas') or {}).get('subject-area')
620
+ if isinstance(sa, dict):
621
+ sa = [sa]
622
+ if isinstance(sa, list):
623
+ subjects = [s['$'] for s in sa if isinstance(s, dict) and '$' in s]
624
+ if subjects:
625
+ uniq = '; '.join(dict.fromkeys(subjects))
626
+ metadata['DE'] = uniq
627
+ metadata['WC'] = uniq
628
+ metadata['SC'] = uniq
629
+
630
+ return metadata
631
+ except Exception as e:
632
+ print(f"Scopus API Error: {str(e)}")
633
+ return {}
634
+
635
+ @lru_cache(maxsize=4096)
636
+ def extract_metadata_from_datacite(doi: str) -> dict:
637
+ """Extract metadata from DataCite API (Free, no key required)"""
638
+ try:
639
+ url = f"https://api.datacite.org/dois/{doi}"
640
+ headers = {
641
+ 'Accept': 'application/vnd.api+json'
642
+ }
643
+ response = _get_with_retry(url, headers=headers)
644
+
645
+ if response.status_code == 200:
646
+ data = response.json()
647
+ if 'data' in data and 'attributes' in data['data']:
648
+ work = data['data']['attributes']
649
+
650
+ # Extract relevant fields
651
+ metadata = {}
652
+
653
+ # Authors
654
+ if 'creators' in work:
655
+ authors = []
656
+ for creator in work['creators']:
657
+ if isinstance(creator, dict):
658
+ name_parts = []
659
+ if 'givenName' in creator:
660
+ name_parts.append(creator['givenName'])
661
+ if 'familyName' in creator:
662
+ name_parts.append(creator['familyName'])
663
+ if name_parts:
664
+ authors.append(' '.join(name_parts))
665
+ elif 'name' in creator:
666
+ authors.append(creator['name'])
667
+ if authors:
668
+ metadata['AU'] = '; '.join(authors)
669
+
670
+ # Title
671
+ if 'titles' in work and work['titles']:
672
+ metadata['TI'] = work['titles'][0].get('title', '')
673
+
674
+ # Publication Year
675
+ if 'publicationYear' in work:
676
+ metadata['PY'] = work['publicationYear']
677
+
678
+ # Document Type
679
+ if 'types' in work and 'resourceTypeGeneral' in work['types']:
680
+ metadata['DT'] = work['types']['resourceTypeGeneral']
681
+
682
+ # Keywords, Science Categories ve Web of Science Categories
683
+ if 'subjects' in work and work['subjects']:
684
+ all_subjects = [subj.get('subject', '') for subj in work['subjects'] if 'subject' in subj]
685
+ if all_subjects:
686
+ metadata['DE'] = '; '.join(all_subjects) # yalnız DE; WC/SC ML'den
687
+
688
+ # Language
689
+ if 'language' in work:
690
+ metadata['LA'] = work['language']
691
+
692
+ # Publisher/Journal
693
+ if 'publisher' in work:
694
+ metadata['SO'] = work['publisher']
695
+
696
+ # Abstract
697
+ if 'descriptions' in work:
698
+ abstracts = [desc.get('description', '') for desc in work['descriptions']
699
+ if desc.get('descriptionType', '').lower() == 'abstract']
700
+ if abstracts:
701
+ metadata['AB'] = abstracts[0]
702
+
703
+ # Contributors (including affiliations)
704
+ if 'contributors' in work:
705
+ affiliations = []
706
+ for contrib in work['contributors']:
707
+ if isinstance(contrib, dict) and 'affiliation' in contrib:
708
+ if isinstance(contrib['affiliation'], list):
709
+ affiliations.extend([aff.get('name', '') for aff in contrib['affiliation'] if 'name' in aff])
710
+ elif isinstance(contrib['affiliation'], str):
711
+ affiliations.append(contrib['affiliation'])
712
+ if affiliations:
713
+ metadata['C1'] = '; '.join(set(affiliations)) # Remove duplicates
714
+
715
+ return metadata
716
+ except Exception as e:
717
+ print(f"DataCite API Error: {str(e)}")
718
+ return {}
719
+
720
+ @lru_cache(maxsize=4096)
721
+ def extract_metadata_from_unpaywall(doi: str, email: str) -> dict:
722
+ """Extract metadata from Unpaywall API (Free, requires email)"""
723
+ try:
724
+ url = f"https://api.unpaywall.org/v2/{doi}?email={email}"
725
+ response = _get_with_retry(url)
726
+
727
+ if response.status_code == 200:
728
+ work = response.json()
729
+ metadata = {}
730
+
731
+ # Authors
732
+ if 'z_authors' in work:
733
+ authors = [f"{a.get('given', '')} {a.get('family', '')}" for a in work['z_authors']]
734
+ metadata['AU'] = '; '.join(authors)
735
+
736
+ # Title
737
+ if 'title' in work:
738
+ metadata['TI'] = work['title']
739
+
740
+ # Journal
741
+ if 'journal_name' in work:
742
+ metadata['SO'] = work['journal_name']
743
+
744
+ # Publication Year
745
+ if 'published_date' in work:
746
+ try:
747
+ metadata['PY'] = work['published_date'][:4]
748
+ except:
749
+ pass
750
+
751
+ # Document Type
752
+ if 'genre' in work:
753
+ metadata['DT'] = work['genre']
754
+
755
+ return metadata
756
+ except Exception as e:
757
+ print(f"Unpaywall API Error: {str(e)}")
758
+ return {}
759
+
760
+ @lru_cache(maxsize=4096)
761
+ def extract_metadata_from_europepmc(doi: str) -> dict:
762
+ """Extract metadata from Europe PMC API (Free)"""
763
+ try:
764
+ url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=DOI:{doi}&format=json"
765
+ response = _get_with_retry(url)
766
+
767
+ if response.status_code == 200:
768
+ data = response.json()
769
+ if 'resultList' in data and 'result' in data['resultList'] and data['resultList']['result']:
770
+ work = data['resultList']['result'][0]
771
+ metadata = {}
772
+
773
+ # DOI
774
+ if 'doi' in work:
775
+ metadata['DI'] = work['doi']
776
+
777
+ # Document Type
778
+ if 'pubType' in work:
779
+ metadata['DT'] = work['pubType']
780
+
781
+ # Title
782
+ if 'title' in work:
783
+ metadata['TI'] = work['title']
784
+
785
+ # Authors
786
+ if 'authorString' in work:
787
+ metadata['AU'] = work['authorString']
788
+
789
+ # Journal Title
790
+ if 'journalTitle' in work:
791
+ metadata['SO'] = work['journalTitle']
792
+
793
+ # Volume
794
+ if 'journalVolume' in work:
795
+ metadata['VL'] = work['journalVolume']
796
+
797
+ # Issue
798
+ if 'journalIssue' in work:
799
+ metadata['IS'] = work['journalIssue']
800
+
801
+ # Page Information
802
+ if 'pageInfo' in work:
803
+ pages = work['pageInfo'].split('-')
804
+ if len(pages) == 2:
805
+ metadata['BP'] = pages[0].strip() # Beginning Page
806
+ metadata['EP'] = pages[1].strip() # Ending Page
807
+
808
+ # Publication Year
809
+ if 'pubYear' in work:
810
+ metadata['PY'] = work['pubYear']
811
+
812
+ # ISSN
813
+ if 'journalIssn' in work:
814
+ metadata['SN'] = work['journalIssn']
815
+
816
+ # Abstract
817
+ if 'abstractText' in work:
818
+ metadata['AB'] = work['abstractText']
819
+
820
+ # URLs
821
+ urls = []
822
+ if 'sourceUrl' in work:
823
+ urls.append(work['sourceUrl'])
824
+ if 'fullTextUrlList' in work and 'fullTextUrl' in work['fullTextUrlList']:
825
+ for url_info in work['fullTextUrlList']['fullTextUrl']:
826
+ if 'url' in url_info:
827
+ urls.append(url_info['url'])
828
+ if urls:
829
+ metadata['UR'] = '; '.join(urls)
830
+
831
+ # Citations
832
+ if 'citedByCount' in work:
833
+ metadata['TC'] = work['citedByCount']
834
+
835
+ # Open Access Information
836
+ if 'isOpenAccess' in work:
837
+ oa_info = []
838
+ oa_info.append(f"is_oa: {work['isOpenAccess']}")
839
+ if 'fullTextUrlList' in work and 'fullTextUrl' in work['fullTextUrlList']:
840
+ for url_info in work['fullTextUrlList']['fullTextUrl']:
841
+ if 'availability' in url_info:
842
+ oa_info.append(f"availability: {url_info['availability']}")
843
+ if 'availabilityCode' in url_info:
844
+ oa_info.append(f"status: {url_info['availabilityCode']}")
845
+ if oa_info:
846
+ metadata['OA'] = '; '.join(oa_info)
847
+
848
+ # Additional Information
849
+ additional_info = []
850
+ if 'inEPMC' in work:
851
+ additional_info.append(f"in_epmc: {work['inEPMC']}")
852
+ if 'inPMC' in work:
853
+ additional_info.append(f"in_pmc: {work['inPMC']}")
854
+ if 'hasPDF' in work:
855
+ additional_info.append(f"has_pdf: {work['hasPDF']}")
856
+ if 'hasReferences' in work:
857
+ additional_info.append(f"has_references: {work['hasReferences']}")
858
+ if 'hasTextMinedTerms' in work:
859
+ additional_info.append(f"has_text_mined_terms: {work['hasTextMinedTerms']}")
860
+ if 'hasDbCrossReferences' in work:
861
+ additional_info.append(f"has_db_cross_references: {work['hasDbCrossReferences']}")
862
+ if additional_info:
863
+ metadata['AI'] = '; '.join(additional_info)
864
+
865
+ return metadata
866
+ except Exception as e:
867
+ print(f"Europe PMC API Error: {str(e)}")
868
+ return {}
869
+
870
+ @lru_cache(maxsize=4096)
871
+ def extract_metadata_from_semantic_scholar(doi: str, api_key: str = None) -> dict:
872
+ """Extract metadata from Semantic Scholar API (Free, API key optional but recommended)"""
873
+ try:
874
+ url = f"https://api.semanticscholar.org/v1/paper/{doi}"
875
+ headers = {'x-api-key': api_key} if api_key else {}
876
+ response = _get_with_retry(url, headers=headers)
877
+
878
+ if response.status_code == 200:
879
+ work = response.json()
880
+ metadata = {}
881
+
882
+ # Paper ID
883
+ if 'paperId' in work:
884
+ metadata['ID'] = work['paperId']
885
+
886
+ # DOI
887
+ if 'externalIds' in work and 'DOI' in work['externalIds']:
888
+ metadata['DI'] = work['externalIds']['DOI']
889
+
890
+ # Title
891
+ if 'title' in work:
892
+ metadata['TI'] = work['title']
893
+
894
+ # Authors
895
+ if 'authors' in work:
896
+ authors = []
897
+ for author in work['authors']:
898
+ if 'name' in author:
899
+ authors.append(author['name'])
900
+
901
+ if authors:
902
+ metadata['AU'] = '; '.join(authors)
903
+
904
+ # Abstract
905
+ if 'abstract' in work:
906
+ metadata['AB'] = work['abstract']
907
+
908
+ # Publication Year
909
+ if 'year' in work:
910
+ metadata['PY'] = work['year']
911
+
912
+ # Citations
913
+ if 'citationCount' in work:
914
+ metadata['TC'] = work['citationCount']
915
+
916
+ # Reference Count
917
+ if 'referenceCount' in work:
918
+ metadata['RC'] = work['referenceCount']
919
+
920
+ # URL
921
+ if 'url' in work:
922
+ metadata['UR'] = work['url']
923
+
924
+ # Fields of Study -> yalnız DE; WC/SC ML'den
925
+ if 'fieldsOfStudy' in work and work['fieldsOfStudy']:
926
+ metadata['DE'] = '; '.join(work['fieldsOfStudy'])
927
+
928
+ # Additional Identifiers
929
+ if 'externalIds' in work:
930
+ external_ids = []
931
+ for id_type, id_value in work['externalIds'].items():
932
+ if id_type != 'DOI': # DOI already handled
933
+ external_ids.append(f"{id_type}: {id_value}")
934
+ if external_ids:
935
+ metadata['EI'] = '; '.join(external_ids)
936
+
937
+ return metadata
938
+ except Exception as e:
939
+ print(f"Semantic Scholar API Error: {str(e)}")
940
+ return {}
941
+
942
+ def truncate_url_list(url_list: str, max_length: int = 2079) -> str:
943
+ """Excel'in URL uzunluk sınırına uygun şekilde URL listesini kısalt"""
944
+ # Eğer URL listesi boşsa veya zaten sınırın altındaysa direkt döndür
945
+ if not url_list or len(url_list) <= max_length:
946
+ return url_list
947
+
948
+ # URL'leri ayır
949
+ urls = url_list.split('; ')
950
+ result = []
951
+ current_length = 0
952
+
953
+ # Her URL için
954
+ for i, url in enumerate(urls):
955
+ # İlk URL için ayırıcı gerekmez, diğerleri için '; ' (2 karakter) gerekir
956
+ separator_length = 0 if not result else 2
957
+
958
+ # Bu URL'yi eklersek toplam uzunluk
959
+ new_length = current_length + len(url) + separator_length
960
+
961
+ # Eğer bu URL'yi eklemek sınırı aşmayacaksa
962
+ if new_length <= max_length - 3: # "..." için 3 karakter rezerve et
963
+ result.append(url)
964
+ current_length = new_length
965
+ else:
966
+ # Sınıra ulaşıldı, "..." ekle ve döngüyü kır
967
+ result.append("...")
968
+ break
969
+
970
+ # URL'leri birleştir
971
+ return '; '.join(result)
972
+
973
+ def extract_metadata(doi: str, current_data: dict, scopus_api_key: str = None, semantic_scholar_key: str = None, unpaywall_email: str = None, crossref_email: str = None) -> dict:
974
+ """Try to extract metadata from multiple sources"""
975
+ metadata = current_data.copy()
976
+ api_sources = {} # Track which fields came from which API
977
+
978
+ # Erken çıkış için: API'lerin doldurabileceği alanlar. OI(ORCID)/RI/ROR/CC yeni
979
+ # eklendi (yazar/kurum kimliği — disambiguation için).
980
+ _FILLABLE = ("DI", "DT", "AU", "AF", "TI", "PY", "SO", "PU", "SN", "UR", "AB", "DE",
981
+ "C1", "TC", "CR", "LA", "WC", "SC", "OI", "RI", "ROR", "CC")
982
+
983
+ def _is_blank(v) -> bool:
984
+ if v is None:
985
+ return True
986
+ try:
987
+ if pd.isna(v):
988
+ return True
989
+ except (TypeError, ValueError):
990
+ pass
991
+ return str(v).strip() in ("", "nan", "NaN", "None")
992
+
993
+ def _remaining() -> bool:
994
+ return any(_is_blank(metadata.get(f)) for f in _FILLABLE)
995
+
996
+ try:
997
+ # Get API credentials from config file
998
+ if not scopus_api_key:
999
+ scopus_api_key = get_api_credential('scopus', 'api_key')
1000
+ if not semantic_scholar_key:
1001
+ semantic_scholar_key = get_api_credential('semantic_scholar', 'api_key')
1002
+ if not unpaywall_email:
1003
+ unpaywall_email = get_api_credential('unpaywall', 'email')
1004
+ if not crossref_email:
1005
+ crossref_email = get_api_credential('crossref', 'email')
1006
+
1007
+ if not scopus_api_key:
1008
+ print("\nWarning: Scopus API key not found in API_config.json")
1009
+ print("You can add it to enable Scopus metadata enrichment.")
1010
+ print("Continuing with other data sources...")
1011
+
1012
+ if not unpaywall_email:
1013
+ print("\nWarning: Unpaywall email not found in API_config.json")
1014
+ print("You can add it to enable Unpaywall metadata enrichment.")
1015
+ print("Continuing with other data sources...")
1016
+
1017
+ # CrossRef
1018
+ print(f"\nTrying CrossRef API...", end='')
1019
+ try:
1020
+ crossref_data = extract_metadata_from_crossref(doi, crossref_email)
1021
+ if crossref_data:
1022
+ for key, value in crossref_data.items():
1023
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1024
+ # URL içeren alanları kısalt
1025
+ if isinstance(value, str) and ('http://' in value or 'https://' in value):
1026
+ value = truncate_url_list(value)
1027
+ metadata[key] = value
1028
+ if pd.notna(value) and str(value).strip() != '':
1029
+ api_sources[key] = 'CrossRef'
1030
+ print(" [SUCCESS]")
1031
+ else:
1032
+ print(" [NO DATA]")
1033
+ except Exception as e:
1034
+ print(f" [ERROR: {str(e)}]")
1035
+
1036
+ # OpenAlex
1037
+ print(f"Trying OpenAlex API...", end='')
1038
+ try:
1039
+ openalex_data = extract_metadata_from_openalex(doi, crossref_email)
1040
+ if openalex_data:
1041
+ for key, value in openalex_data.items():
1042
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1043
+ # URL içeren alanları kısalt
1044
+ if isinstance(value, str) and ('http://' in value or 'https://' in value):
1045
+ value = truncate_url_list(value)
1046
+ metadata[key] = value
1047
+ if pd.notna(value) and str(value).strip() != '':
1048
+ api_sources[key] = 'OpenAlex'
1049
+ print(" [SUCCESS]")
1050
+ else:
1051
+ print(" [NO DATA]")
1052
+ except Exception as e:
1053
+ print(f" [ERROR: {str(e)}]")
1054
+
1055
+ # Erken çıkış: CrossRef + OpenAlex sonrası doldurulacak alan kalmadıysa
1056
+ # diğer 5 API'yi hiç deneme (en büyük hız kazancı).
1057
+ if not _remaining():
1058
+ for field in metadata:
1059
+ if isinstance(metadata[field], str) and ('http://' in metadata[field] or 'https://' in metadata[field]):
1060
+ metadata[field] = truncate_url_list(metadata[field])
1061
+ if api_sources:
1062
+ metadata['API_Sources'] = api_sources
1063
+ return metadata
1064
+
1065
+ # Scopus
1066
+ if scopus_api_key:
1067
+ print(f"Trying Scopus API...", end='')
1068
+ try:
1069
+ scopus_data = extract_metadata_from_scopus(doi, scopus_api_key)
1070
+ if scopus_data:
1071
+ for key, value in scopus_data.items():
1072
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1073
+ metadata[key] = value
1074
+ if pd.notna(value) and str(value).strip() != '':
1075
+ api_sources[key] = 'Scopus'
1076
+ print(" [SUCCESS]")
1077
+ else:
1078
+ response = requests.get(f"https://api.elsevier.com/content/abstract/doi/{doi}",
1079
+ headers={'X-ELS-APIKey': scopus_api_key, 'Accept': 'application/json'}, timeout=REQUEST_TIMEOUT)
1080
+ print(f" [NO DATA - Status: {response.status_code}, Response: {response.text[:100]}...]")
1081
+ except Exception as e:
1082
+ print(f" [ERROR: {str(e)}]")
1083
+
1084
+ # DataCite
1085
+ print(f"Trying DataCite API...", end='')
1086
+ try:
1087
+ response = requests.get(f"https://api.datacite.org/dois/{doi}", headers={'Accept': 'application/vnd.api+json'}, timeout=REQUEST_TIMEOUT)
1088
+ if response.status_code != 200:
1089
+ print(f" [NO DATA - Status: {response.status_code}]")
1090
+ else:
1091
+ datacite_data = extract_metadata_from_datacite(doi)
1092
+ if datacite_data:
1093
+ for key, value in datacite_data.items():
1094
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1095
+ metadata[key] = value
1096
+ if pd.notna(value) and str(value).strip() != '':
1097
+ api_sources[key] = 'DataCite'
1098
+ print(" [SUCCESS]")
1099
+ else:
1100
+ print(f" [NO DATA - Empty Response]")
1101
+ except Exception as e:
1102
+ print(f" [ERROR: {str(e)}]")
1103
+
1104
+ # Unpaywall
1105
+ if unpaywall_email:
1106
+ print(f"Trying Unpaywall API...", end='')
1107
+ try:
1108
+ unpaywall_data = extract_metadata_from_unpaywall(doi, unpaywall_email)
1109
+ if unpaywall_data:
1110
+ for key, value in unpaywall_data.items():
1111
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1112
+ metadata[key] = value
1113
+ if pd.notna(value) and str(value).strip() != '':
1114
+ api_sources[key] = 'Unpaywall'
1115
+ print(" [SUCCESS]")
1116
+ else:
1117
+ print(" [NO DATA]")
1118
+ except Exception as e:
1119
+ print(f" [ERROR: {str(e)}]")
1120
+
1121
+ # Europe PMC
1122
+ print(f"Trying Europe PMC API...", end='')
1123
+ try:
1124
+ response = requests.get(f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=DOI:{doi}&format=json", timeout=REQUEST_TIMEOUT)
1125
+ if response.status_code != 200:
1126
+ print(f" [NO DATA - Status: {response.status_code}]")
1127
+ else:
1128
+ europepmc_data = extract_metadata_from_europepmc(doi)
1129
+ if europepmc_data:
1130
+ for key, value in europepmc_data.items():
1131
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1132
+ metadata[key] = value
1133
+ if pd.notna(value) and str(value).strip() != '':
1134
+ api_sources[key] = 'Europe PMC'
1135
+ print(" [SUCCESS]")
1136
+ else:
1137
+ data = response.json()
1138
+ hit_count = data.get('hitCount', 0)
1139
+ print(f" [NO DATA - Hit Count: {hit_count}]")
1140
+ except Exception as e:
1141
+ print(f" [ERROR: {str(e)}]")
1142
+
1143
+ # Semantic Scholar
1144
+ print(f"Trying Semantic Scholar API...", end='')
1145
+ try:
1146
+ semantic_data = extract_metadata_from_semantic_scholar(doi, semantic_scholar_key)
1147
+ if semantic_data:
1148
+ for key, value in semantic_data.items():
1149
+ if pd.isna(metadata.get(key, None)) or str(metadata.get(key, '')).strip() == '':
1150
+ metadata[key] = value
1151
+ if pd.notna(value) and str(value).strip() != '':
1152
+ api_sources[key] = 'Semantic Scholar'
1153
+ print(" [SUCCESS]")
1154
+ else:
1155
+ print(" [NO DATA]")
1156
+ except Exception as e:
1157
+ print(f" [ERROR: {str(e)}]")
1158
+
1159
+ # API kaynaklarını ekle
1160
+ if api_sources:
1161
+ metadata['API_Sources'] = api_sources
1162
+
1163
+ # Son kontrol: Tüm URL içeren alanları kontrol et ve gerekirse kısalt
1164
+ for field in metadata:
1165
+ if isinstance(metadata[field], str) and ('http://' in metadata[field] or 'https://' in metadata[field]):
1166
+ metadata[field] = truncate_url_list(metadata[field])
1167
+
1168
+ return metadata
1169
+
1170
+ except Exception as e:
1171
+ print(f"Error processing DOI {doi}: {str(e)}")
1172
+ return current_data