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 ADDED
@@ -0,0 +1,7 @@
1
+ """BibCheck: 参考文献存在性验证工具。
2
+
3
+ 通过学术数据库 API (CrossRef, DBLP, Semantic Scholar) 批量验证
4
+ BibTeX 或 PDF 中的参考文献是否真实存在。
5
+ """
6
+
7
+ __version__ = "0.2.0"
bibcheck/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """CLI 入口: python -m bibcheck"""
2
+
3
+ from bibcheck.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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