bibcheck-ref 0.2.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.
- bibcheck/__init__.py +7 -0
- bibcheck/__main__.py +6 -0
- bibcheck/apis/__init__.py +1 -0
- bibcheck/apis/arxiv.py +127 -0
- bibcheck/apis/base.py +280 -0
- bibcheck/apis/crossref.py +119 -0
- bibcheck/apis/dblp.py +96 -0
- bibcheck/apis/openalex.py +116 -0
- bibcheck/apis/opencitations.py +96 -0
- bibcheck/apis/semantic_scholar.py +129 -0
- bibcheck/apis/serpapi_scholar.py +78 -0
- bibcheck/cache.py +162 -0
- bibcheck/cli.py +204 -0
- bibcheck/config.py +95 -0
- bibcheck/exporter.py +392 -0
- bibcheck/matching.py +170 -0
- bibcheck/models.py +92 -0
- bibcheck/parsers/__init__.py +1 -0
- bibcheck/parsers/bibtex_parser.py +212 -0
- bibcheck/parsers/pdf_parser.py +153 -0
- bibcheck/report/__init__.py +1 -0
- bibcheck/report/html.py +37 -0
- bibcheck/report/markdown.py +110 -0
- bibcheck/report/templates/report.html +193 -0
- bibcheck/verifier.py +441 -0
- bibcheck/web/__init__.py +1 -0
- bibcheck/web/app.py +150 -0
- bibcheck/web/templates/index.html +173 -0
- bibcheck_ref-0.2.0.dist-info/METADATA +21 -0
- bibcheck_ref-0.2.0.dist-info/RECORD +33 -0
- bibcheck_ref-0.2.0.dist-info/WHEEL +5 -0
- bibcheck_ref-0.2.0.dist-info/entry_points.txt +2 -0
- bibcheck_ref-0.2.0.dist-info/top_level.txt +1 -0
bibcheck/__init__.py
ADDED
bibcheck/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BibCheck API 客户端。"""
|
bibcheck/apis/arxiv.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""arXiv API 客户端。
|
|
2
|
+
|
|
3
|
+
专门用于预印本论文检索(CS、物理、数学等)。
|
|
4
|
+
|
|
5
|
+
支持:
|
|
6
|
+
- 标题搜索: GET /api/query?search_query=ti:...
|
|
7
|
+
- 返回 Atom XML 格式
|
|
8
|
+
|
|
9
|
+
API 文档: https://info.arxiv.org/help/api/
|
|
10
|
+
注意: arXiv 建议每 3 秒不超过 1 次请求。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
from bibcheck.apis.base import BaseAPIClient
|
|
22
|
+
from bibcheck.models import APIResult
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("bibcheck")
|
|
25
|
+
|
|
26
|
+
# Atom / arXiv 命名空间
|
|
27
|
+
_NS = {
|
|
28
|
+
"atom": "http://www.w3.org/2005/Atom",
|
|
29
|
+
"arxiv": "http://arxiv.org/schemas/atom",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ArXivClient(BaseAPIClient):
|
|
34
|
+
"""arXiv 预印本搜索。"""
|
|
35
|
+
|
|
36
|
+
name = "arxiv"
|
|
37
|
+
BASE_URL = "http://export.arxiv.org/api/query"
|
|
38
|
+
|
|
39
|
+
def __init__(self, rate_limit: float = 0.33, **kwargs):
|
|
40
|
+
super().__init__(rate_limit=rate_limit, **kwargs)
|
|
41
|
+
|
|
42
|
+
def search(
|
|
43
|
+
self,
|
|
44
|
+
title: str,
|
|
45
|
+
authors: Optional[List[str]] = None,
|
|
46
|
+
year: Optional[int] = None,
|
|
47
|
+
) -> List[APIResult]:
|
|
48
|
+
# 构建搜索查询
|
|
49
|
+
query = f"ti:{quote(title)}"
|
|
50
|
+
if authors:
|
|
51
|
+
# 取第一作者的姓氏缩小范围
|
|
52
|
+
first_author = authors[0]
|
|
53
|
+
last_name = first_author.split()[-1] if first_author else ""
|
|
54
|
+
if last_name:
|
|
55
|
+
query += f"+AND+au:{quote(last_name)}"
|
|
56
|
+
|
|
57
|
+
params = {
|
|
58
|
+
"search_query": query,
|
|
59
|
+
"max_results": 5,
|
|
60
|
+
"sortBy": "relevance",
|
|
61
|
+
"sortOrder": "descending",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resp = self._request_raw("GET", self.BASE_URL, params=params)
|
|
65
|
+
if resp is None:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
root = ET.fromstring(resp.text)
|
|
70
|
+
except ET.ParseError as e:
|
|
71
|
+
logger.warning(f"[arxiv] XML parse error: {e}")
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
for entry in root.findall("atom:entry", _NS):
|
|
76
|
+
parsed = self._parse_entry(entry)
|
|
77
|
+
if parsed:
|
|
78
|
+
results.append(parsed)
|
|
79
|
+
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _parse_entry(entry: ET.Element) -> Optional[APIResult]:
|
|
84
|
+
"""解析 Atom XML 中的单条 entry。"""
|
|
85
|
+
# 标题(可能含换行)
|
|
86
|
+
title_el = entry.find("atom:title", _NS)
|
|
87
|
+
title = " ".join((title_el.text or "").split()) if title_el is not None else ""
|
|
88
|
+
if not title:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# 作者
|
|
92
|
+
authors = []
|
|
93
|
+
for author_el in entry.findall("atom:author", _NS):
|
|
94
|
+
name_el = author_el.find("atom:name", _NS)
|
|
95
|
+
if name_el is not None and name_el.text:
|
|
96
|
+
authors.append(name_el.text.strip())
|
|
97
|
+
|
|
98
|
+
# 发布日期 → 年份
|
|
99
|
+
published_el = entry.find("atom:published", _NS)
|
|
100
|
+
year = None
|
|
101
|
+
if published_el is not None and published_el.text:
|
|
102
|
+
match = re.match(r"(\d{4})", published_el.text)
|
|
103
|
+
if match:
|
|
104
|
+
year = int(match.group(1))
|
|
105
|
+
|
|
106
|
+
# URL (arXiv abs 链接)
|
|
107
|
+
url = ""
|
|
108
|
+
id_el = entry.find("atom:id", _NS)
|
|
109
|
+
if id_el is not None and id_el.text:
|
|
110
|
+
url = id_el.text.strip()
|
|
111
|
+
|
|
112
|
+
# DOI(如果有)
|
|
113
|
+
doi = None
|
|
114
|
+
doi_el = entry.find("arxiv:doi", _NS)
|
|
115
|
+
if doi_el is not None and doi_el.text:
|
|
116
|
+
doi = doi_el.text.strip()
|
|
117
|
+
|
|
118
|
+
return APIResult(
|
|
119
|
+
source="arxiv",
|
|
120
|
+
title=title,
|
|
121
|
+
authors=authors,
|
|
122
|
+
year=year,
|
|
123
|
+
doi=doi,
|
|
124
|
+
venue="arXiv",
|
|
125
|
+
url=url,
|
|
126
|
+
raw_response={},
|
|
127
|
+
)
|
bibcheck/apis/base.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""API 客户端抽象基类。
|
|
2
|
+
|
|
3
|
+
改进点:
|
|
4
|
+
- 指数退避重试(自动处理 HTTP 429 / 5xx)
|
|
5
|
+
- 解析 Retry-After header
|
|
6
|
+
- 请求失败计数与熔断
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
import threading
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from bibcheck.models import APIResult
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("bibcheck")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimiter:
|
|
25
|
+
"""令牌桶速率限制器(线程安全)。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, rate: float):
|
|
28
|
+
"""rate: 每秒允许的请求数。"""
|
|
29
|
+
self._min_interval = 1.0 / rate if rate > 0 else 0
|
|
30
|
+
self._last_call = 0.0
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def wait(self):
|
|
34
|
+
with self._lock:
|
|
35
|
+
now = time.time()
|
|
36
|
+
elapsed = now - self._last_call
|
|
37
|
+
if elapsed < self._min_interval:
|
|
38
|
+
time.sleep(self._min_interval - elapsed)
|
|
39
|
+
self._last_call = time.time()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseAPIClient(ABC):
|
|
43
|
+
"""API 客户端抽象基类,含速率限制、指数退避重试和熔断机制。"""
|
|
44
|
+
|
|
45
|
+
name: str = "base"
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
rate_limit: float = 1.0,
|
|
50
|
+
timeout: int = 15,
|
|
51
|
+
max_retries: int = 2,
|
|
52
|
+
base_backoff: float = 2.0,
|
|
53
|
+
):
|
|
54
|
+
self._limiter = RateLimiter(rate_limit)
|
|
55
|
+
self._timeout = timeout
|
|
56
|
+
self._max_retries = max_retries
|
|
57
|
+
self._base_backoff = base_backoff
|
|
58
|
+
self._session = requests.Session()
|
|
59
|
+
self._session.headers.update({
|
|
60
|
+
"User-Agent": "BibCheck/0.1 (Academic Reference Verification Tool; mailto:bibcheck@example.com)"
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
# 请求统计
|
|
64
|
+
self.request_count = 0
|
|
65
|
+
self.error_count = 0
|
|
66
|
+
|
|
67
|
+
# 熔断器: 连续 429 次数过多时暂时禁用
|
|
68
|
+
self._consecutive_429 = 0
|
|
69
|
+
self._circuit_open_until = 0.0 # 熔断恢复时间戳
|
|
70
|
+
self._circuit_open_count = 0
|
|
71
|
+
self._lock = threading.Lock()
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def search(
|
|
75
|
+
self,
|
|
76
|
+
title: str,
|
|
77
|
+
authors: Optional[List[str]] = None,
|
|
78
|
+
year: Optional[int] = None,
|
|
79
|
+
) -> List[APIResult]:
|
|
80
|
+
"""搜索学术数据库,返回匹配结果。"""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def _get(self, url: str, params: dict = None) -> dict:
|
|
84
|
+
"""带速率限制和指数退避重试的 GET 请求。"""
|
|
85
|
+
return self._request("GET", url, params=params)
|
|
86
|
+
|
|
87
|
+
def _post(self, url: str, json_data: dict = None, params: dict = None) -> dict:
|
|
88
|
+
"""带速率限制和指数退避重试的 POST 请求。"""
|
|
89
|
+
return self._request("POST", url, params=params, json_data=json_data)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_circuit_open(self) -> bool:
|
|
93
|
+
"""熔断器是否打开(API 暂时不可用)。"""
|
|
94
|
+
if self._circuit_open_until <= 0:
|
|
95
|
+
return False
|
|
96
|
+
if time.time() >= self._circuit_open_until:
|
|
97
|
+
# 熔断期结束,半开状态允许重试
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._circuit_open_until = 0
|
|
100
|
+
self._consecutive_429 = 0
|
|
101
|
+
return False
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def _trip_circuit(self):
|
|
105
|
+
"""触发熔断,暂停该 API 一段时间。"""
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._circuit_open_count += 1
|
|
108
|
+
# 熔断时间递增: 60s, 120s, 300s
|
|
109
|
+
cooldown = min(60 * (2 ** (self._circuit_open_count - 1)), 300)
|
|
110
|
+
self._circuit_open_until = time.time() + cooldown
|
|
111
|
+
logger.warning(
|
|
112
|
+
f"[{self.name}] Circuit breaker OPEN — pausing for {cooldown}s "
|
|
113
|
+
f"(trip #{self._circuit_open_count})"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _request(
|
|
117
|
+
self,
|
|
118
|
+
method: str,
|
|
119
|
+
url: str,
|
|
120
|
+
params: dict = None,
|
|
121
|
+
json_data: dict = None,
|
|
122
|
+
) -> dict:
|
|
123
|
+
"""通用请求方法,含重试逻辑和熔断机制。"""
|
|
124
|
+
# 熔断器检查
|
|
125
|
+
if self.is_circuit_open:
|
|
126
|
+
return {"error": f"[{self.name}] circuit breaker open, skipping"}
|
|
127
|
+
|
|
128
|
+
last_error = None
|
|
129
|
+
|
|
130
|
+
for attempt in range(self._max_retries + 1):
|
|
131
|
+
self._limiter.wait()
|
|
132
|
+
self.request_count += 1
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
if method == "GET":
|
|
136
|
+
resp = self._session.get(url, params=params, timeout=self._timeout)
|
|
137
|
+
else:
|
|
138
|
+
resp = self._session.post(
|
|
139
|
+
url, params=params, json=json_data, timeout=self._timeout
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# 成功 — 重置 429 计数
|
|
143
|
+
if resp.status_code == 200:
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._consecutive_429 = 0
|
|
146
|
+
return resp.json()
|
|
147
|
+
|
|
148
|
+
# 429 Too Many Requests
|
|
149
|
+
if resp.status_code == 429:
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._consecutive_429 += 1
|
|
152
|
+
|
|
153
|
+
# 连续 3 次 429 → 触发熔断
|
|
154
|
+
if self._consecutive_429 >= 3:
|
|
155
|
+
self._trip_circuit()
|
|
156
|
+
return {"error": f"[{self.name}] rate limited, circuit breaker tripped"}
|
|
157
|
+
|
|
158
|
+
retry_after = self._parse_retry_after(resp)
|
|
159
|
+
wait_time = retry_after or (self._base_backoff * (2 ** attempt))
|
|
160
|
+
logger.info(
|
|
161
|
+
f"[{self.name}] 429, waiting {wait_time:.0f}s "
|
|
162
|
+
f"(attempt {attempt + 1}/{self._max_retries + 1})"
|
|
163
|
+
)
|
|
164
|
+
time.sleep(wait_time)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# 5xx 服务器错误 — 重试
|
|
168
|
+
if resp.status_code >= 500:
|
|
169
|
+
wait_time = self._base_backoff * (2 ** attempt)
|
|
170
|
+
time.sleep(wait_time)
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# 其他 4xx — 不重试
|
|
174
|
+
self.error_count += 1
|
|
175
|
+
return {"error": f"HTTP {resp.status_code}"}
|
|
176
|
+
|
|
177
|
+
except requests.exceptions.Timeout:
|
|
178
|
+
last_error = "timeout"
|
|
179
|
+
time.sleep(self._base_backoff * (2 ** attempt))
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
except requests.exceptions.ConnectionError:
|
|
183
|
+
last_error = "connection error"
|
|
184
|
+
time.sleep(self._base_backoff * (2 ** attempt))
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
except (requests.RequestException, ValueError) as e:
|
|
188
|
+
self.error_count += 1
|
|
189
|
+
return {"error": str(e)}
|
|
190
|
+
|
|
191
|
+
self.error_count += 1
|
|
192
|
+
return {"error": f"Max retries: {last_error}"}
|
|
193
|
+
|
|
194
|
+
def _request_raw(
|
|
195
|
+
self,
|
|
196
|
+
method: str,
|
|
197
|
+
url: str,
|
|
198
|
+
params: dict = None,
|
|
199
|
+
) -> Optional[requests.Response]:
|
|
200
|
+
"""通用请求方法,返回原始 Response 对象(不解析 JSON)。
|
|
201
|
+
|
|
202
|
+
用于 arXiv 等返回 XML 格式的 API。
|
|
203
|
+
复用与 _request 相同的速率限制、重试和熔断逻辑。
|
|
204
|
+
"""
|
|
205
|
+
if self.is_circuit_open:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
last_error = None
|
|
209
|
+
|
|
210
|
+
for attempt in range(self._max_retries + 1):
|
|
211
|
+
self._limiter.wait()
|
|
212
|
+
self.request_count += 1
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
if method == "GET":
|
|
216
|
+
resp = self._session.get(url, params=params, timeout=self._timeout)
|
|
217
|
+
else:
|
|
218
|
+
resp = self._session.post(url, params=params, timeout=self._timeout)
|
|
219
|
+
|
|
220
|
+
if resp.status_code == 200:
|
|
221
|
+
with self._lock:
|
|
222
|
+
self._consecutive_429 = 0
|
|
223
|
+
return resp
|
|
224
|
+
|
|
225
|
+
if resp.status_code == 429:
|
|
226
|
+
with self._lock:
|
|
227
|
+
self._consecutive_429 += 1
|
|
228
|
+
if self._consecutive_429 >= 3:
|
|
229
|
+
self._trip_circuit()
|
|
230
|
+
return None
|
|
231
|
+
retry_after = self._parse_retry_after(resp)
|
|
232
|
+
wait_time = retry_after or (self._base_backoff * (2 ** attempt))
|
|
233
|
+
logger.info(
|
|
234
|
+
f"[{self.name}] 429, waiting {wait_time:.0f}s "
|
|
235
|
+
f"(attempt {attempt + 1}/{self._max_retries + 1})"
|
|
236
|
+
)
|
|
237
|
+
time.sleep(wait_time)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if resp.status_code >= 500:
|
|
241
|
+
time.sleep(self._base_backoff * (2 ** attempt))
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
self.error_count += 1
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
except requests.exceptions.Timeout:
|
|
248
|
+
last_error = "timeout"
|
|
249
|
+
time.sleep(self._base_backoff * (2 ** attempt))
|
|
250
|
+
continue
|
|
251
|
+
except requests.exceptions.ConnectionError:
|
|
252
|
+
last_error = "connection error"
|
|
253
|
+
time.sleep(self._base_backoff * (2 ** attempt))
|
|
254
|
+
continue
|
|
255
|
+
except requests.RequestException:
|
|
256
|
+
self.error_count += 1
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
self.error_count += 1
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _parse_retry_after(resp: requests.Response) -> Optional[float]:
|
|
264
|
+
"""解析 Retry-After header(秒数或日期)。"""
|
|
265
|
+
retry_after = resp.headers.get("Retry-After")
|
|
266
|
+
if retry_after is None:
|
|
267
|
+
return None
|
|
268
|
+
try:
|
|
269
|
+
return float(retry_after)
|
|
270
|
+
except ValueError:
|
|
271
|
+
# 可能是 HTTP 日期格式,保守等待 10 秒
|
|
272
|
+
return 10.0
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def stats(self) -> dict:
|
|
276
|
+
return {
|
|
277
|
+
"name": self.name,
|
|
278
|
+
"requests": self.request_count,
|
|
279
|
+
"errors": self.error_count,
|
|
280
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""CrossRef API 客户端。
|
|
2
|
+
|
|
3
|
+
支持:
|
|
4
|
+
- 标题搜索: GET /works?query.bibliographic=...
|
|
5
|
+
- DOI 直查: GET /works/{doi}
|
|
6
|
+
- Polite Pool: 通过 mailto 参数获得更稳定的服务
|
|
7
|
+
|
|
8
|
+
API 文档: https://api.crossref.org/swagger-ui/index.html
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from bibcheck.apis.base import BaseAPIClient
|
|
16
|
+
from bibcheck.models import APIResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CrossRefClient(BaseAPIClient):
|
|
20
|
+
"""CrossRef 学术文献搜索。"""
|
|
21
|
+
|
|
22
|
+
name = "crossref"
|
|
23
|
+
BASE_URL = "https://api.crossref.org/works"
|
|
24
|
+
|
|
25
|
+
def __init__(self, rate_limit: float = 5.0, mailto: Optional[str] = None, **kwargs):
|
|
26
|
+
super().__init__(rate_limit=rate_limit, **kwargs)
|
|
27
|
+
self._mailto = mailto
|
|
28
|
+
# CrossRef 的 polite pool 通过 mailto 激活
|
|
29
|
+
if mailto:
|
|
30
|
+
self._session.headers["User-Agent"] = (
|
|
31
|
+
f"BibCheck/0.1 (https://github.com/bibcheck; mailto:{mailto})"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def search(
|
|
35
|
+
self,
|
|
36
|
+
title: str,
|
|
37
|
+
authors: Optional[List[str]] = None,
|
|
38
|
+
year: Optional[int] = None,
|
|
39
|
+
) -> List[APIResult]:
|
|
40
|
+
# 同时用 query.title(精确标题匹配)和 query.bibliographic(宽泛匹配)
|
|
41
|
+
# query.title 对期刊论文、统计学论文等非 CS 文献效果更好
|
|
42
|
+
params = {
|
|
43
|
+
"query.title": title,
|
|
44
|
+
"rows": 5,
|
|
45
|
+
"select": "DOI,title,author,published-print,published-online,container-title,URL",
|
|
46
|
+
}
|
|
47
|
+
if self._mailto:
|
|
48
|
+
params["mailto"] = self._mailto
|
|
49
|
+
|
|
50
|
+
data = self._get(self.BASE_URL, params=params)
|
|
51
|
+
if "error" in data or "message" not in data:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
results = [self._parse_item(item) for item in data["message"].get("items", [])]
|
|
55
|
+
|
|
56
|
+
# 如果 query.title 没结果,fallback 到 query.bibliographic
|
|
57
|
+
if not results:
|
|
58
|
+
params.pop("query.title")
|
|
59
|
+
params["query.bibliographic"] = title
|
|
60
|
+
data = self._get(self.BASE_URL, params=params)
|
|
61
|
+
if "error" not in data and "message" in data:
|
|
62
|
+
results = [self._parse_item(item) for item in data["message"].get("items", [])]
|
|
63
|
+
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
def lookup_by_doi(self, doi: str) -> Optional[APIResult]:
|
|
67
|
+
"""通过 DOI 直接查询(精确匹配,最快最可靠)。"""
|
|
68
|
+
data = self._get(f"{self.BASE_URL}/{doi}")
|
|
69
|
+
if "error" in data or "message" not in data:
|
|
70
|
+
return None
|
|
71
|
+
return self._parse_item(data["message"])
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _parse_item(item: dict) -> APIResult:
|
|
75
|
+
"""解析 CrossRef 返回的单条记录。"""
|
|
76
|
+
# 标题
|
|
77
|
+
item_title = ""
|
|
78
|
+
if item.get("title"):
|
|
79
|
+
item_title = item["title"][0] if isinstance(item["title"], list) else item["title"]
|
|
80
|
+
|
|
81
|
+
# 作者
|
|
82
|
+
item_authors = []
|
|
83
|
+
for author in item.get("author", []):
|
|
84
|
+
name_parts = []
|
|
85
|
+
if author.get("given"):
|
|
86
|
+
name_parts.append(author["given"])
|
|
87
|
+
if author.get("family"):
|
|
88
|
+
name_parts.append(author["family"])
|
|
89
|
+
if name_parts:
|
|
90
|
+
item_authors.append(" ".join(name_parts))
|
|
91
|
+
|
|
92
|
+
# 年份
|
|
93
|
+
item_year = None
|
|
94
|
+
for date_field in ("published-print", "published-online"):
|
|
95
|
+
if item.get(date_field, {}).get("date-parts"):
|
|
96
|
+
parts = item[date_field]["date-parts"][0]
|
|
97
|
+
if parts and parts[0]:
|
|
98
|
+
item_year = parts[0]
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
# 期刊/会议
|
|
102
|
+
venue = ""
|
|
103
|
+
if item.get("container-title"):
|
|
104
|
+
venue = (
|
|
105
|
+
item["container-title"][0]
|
|
106
|
+
if isinstance(item["container-title"], list)
|
|
107
|
+
else item["container-title"]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return APIResult(
|
|
111
|
+
source="crossref",
|
|
112
|
+
title=item_title,
|
|
113
|
+
authors=item_authors,
|
|
114
|
+
year=item_year,
|
|
115
|
+
doi=item.get("DOI"),
|
|
116
|
+
venue=venue,
|
|
117
|
+
url=item.get("URL"),
|
|
118
|
+
raw_response=item,
|
|
119
|
+
)
|
bibcheck/apis/dblp.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""DBLP API 客户端。
|
|
2
|
+
|
|
3
|
+
API 文档: https://dblp.org/faq/How+to+use+the+dblp+search+API.html
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from bibcheck.apis.base import BaseAPIClient
|
|
12
|
+
from bibcheck.models import APIResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DBLPClient(BaseAPIClient):
|
|
16
|
+
"""DBLP 计算机科学文献搜索。"""
|
|
17
|
+
|
|
18
|
+
name = "dblp"
|
|
19
|
+
BASE_URL = "https://dblp.org/search/publ/api"
|
|
20
|
+
|
|
21
|
+
def __init__(self, rate_limit: float = 1.0, **kwargs):
|
|
22
|
+
super().__init__(rate_limit=rate_limit, **kwargs)
|
|
23
|
+
|
|
24
|
+
def search(
|
|
25
|
+
self,
|
|
26
|
+
title: str,
|
|
27
|
+
authors: Optional[List[str]] = None,
|
|
28
|
+
year: Optional[int] = None,
|
|
29
|
+
) -> List[APIResult]:
|
|
30
|
+
params = {
|
|
31
|
+
"q": title,
|
|
32
|
+
"format": "json",
|
|
33
|
+
"h": 5, # 返回条数
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
data = self._get(self.BASE_URL, params=params)
|
|
37
|
+
if "error" in data:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
hits = data.get("result", {}).get("hits", {}).get("hit", [])
|
|
41
|
+
if not hits:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
results = []
|
|
45
|
+
for hit in hits:
|
|
46
|
+
info = hit.get("info", {})
|
|
47
|
+
|
|
48
|
+
# 提取标题
|
|
49
|
+
item_title = info.get("title", "")
|
|
50
|
+
# DBLP 标题可能以句号结尾
|
|
51
|
+
item_title = item_title.rstrip(".")
|
|
52
|
+
|
|
53
|
+
# 提取作者
|
|
54
|
+
item_authors = []
|
|
55
|
+
authors_data = info.get("authors", {}).get("author", [])
|
|
56
|
+
if isinstance(authors_data, dict):
|
|
57
|
+
authors_data = [authors_data]
|
|
58
|
+
for author in authors_data:
|
|
59
|
+
if isinstance(author, dict):
|
|
60
|
+
item_authors.append(author.get("text", ""))
|
|
61
|
+
elif isinstance(author, str):
|
|
62
|
+
item_authors.append(author)
|
|
63
|
+
|
|
64
|
+
# 提取年份
|
|
65
|
+
item_year = None
|
|
66
|
+
if info.get("year"):
|
|
67
|
+
try:
|
|
68
|
+
item_year = int(info["year"])
|
|
69
|
+
except (ValueError, TypeError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# 提取 DOI
|
|
73
|
+
doi = None
|
|
74
|
+
if info.get("doi"):
|
|
75
|
+
doi = info["doi"]
|
|
76
|
+
# DBLP 的 doi 可能包含前缀
|
|
77
|
+
if doi and not doi.startswith("10."):
|
|
78
|
+
doi_match = re.search(r"(10\.\d{4,}/\S+)", doi)
|
|
79
|
+
if doi_match:
|
|
80
|
+
doi = doi_match.group(1)
|
|
81
|
+
|
|
82
|
+
# 提取期刊/会议
|
|
83
|
+
venue = info.get("venue", "")
|
|
84
|
+
|
|
85
|
+
results.append(APIResult(
|
|
86
|
+
source=self.name,
|
|
87
|
+
title=item_title,
|
|
88
|
+
authors=item_authors,
|
|
89
|
+
year=item_year,
|
|
90
|
+
doi=doi,
|
|
91
|
+
venue=venue,
|
|
92
|
+
url=info.get("url", ""),
|
|
93
|
+
raw_response=info,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
return results
|