crossref-local 0.4.0__py3-none-any.whl → 0.5.1__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 (50) hide show
  1. crossref_local/__init__.py +24 -10
  2. crossref_local/_aio/__init__.py +30 -0
  3. crossref_local/_aio/_impl.py +238 -0
  4. crossref_local/_cache/__init__.py +15 -0
  5. crossref_local/{cache_export.py → _cache/export.py} +27 -10
  6. crossref_local/_cache/utils.py +93 -0
  7. crossref_local/_cli/__init__.py +9 -0
  8. crossref_local/_cli/cli.py +389 -0
  9. crossref_local/_cli/mcp.py +351 -0
  10. crossref_local/_cli/mcp_server.py +457 -0
  11. crossref_local/_cli/search.py +199 -0
  12. crossref_local/_core/__init__.py +62 -0
  13. crossref_local/{api.py → _core/api.py} +26 -5
  14. crossref_local/{citations.py → _core/citations.py} +55 -26
  15. crossref_local/{config.py → _core/config.py} +40 -22
  16. crossref_local/{db.py → _core/db.py} +32 -26
  17. crossref_local/_core/export.py +344 -0
  18. crossref_local/{fts.py → _core/fts.py} +37 -14
  19. crossref_local/{models.py → _core/models.py} +120 -6
  20. crossref_local/_remote/__init__.py +56 -0
  21. crossref_local/_remote/base.py +378 -0
  22. crossref_local/_remote/collections.py +175 -0
  23. crossref_local/_server/__init__.py +140 -0
  24. crossref_local/_server/middleware.py +25 -0
  25. crossref_local/_server/models.py +143 -0
  26. crossref_local/_server/routes_citations.py +98 -0
  27. crossref_local/_server/routes_collections.py +282 -0
  28. crossref_local/_server/routes_compat.py +102 -0
  29. crossref_local/_server/routes_works.py +178 -0
  30. crossref_local/_server/server.py +19 -0
  31. crossref_local/aio.py +30 -206
  32. crossref_local/cache.py +100 -100
  33. crossref_local/cli.py +5 -515
  34. crossref_local/jobs.py +169 -0
  35. crossref_local/mcp_server.py +5 -410
  36. crossref_local/remote.py +5 -266
  37. crossref_local/server.py +5 -349
  38. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/METADATA +36 -11
  39. crossref_local-0.5.1.dist-info/RECORD +49 -0
  40. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/entry_points.txt +1 -1
  41. crossref_local/cli_mcp.py +0 -275
  42. crossref_local-0.4.0.dist-info/RECORD +0 -27
  43. /crossref_local/{cache_viz.py → _cache/viz.py} +0 -0
  44. /crossref_local/{cli_cache.py → _cli/cache.py} +0 -0
  45. /crossref_local/{cli_completion.py → _cli/completion.py} +0 -0
  46. /crossref_local/{cli_main.py → _cli/main.py} +0 -0
  47. /crossref_local/{impact_factor → _impact_factor}/__init__.py +0 -0
  48. /crossref_local/{impact_factor → _impact_factor}/calculator.py +0 -0
  49. /crossref_local/{impact_factor → _impact_factor}/journal_lookup.py +0 -0
  50. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,378 @@
1
+ """Remote API client for crossref_local.
2
+
3
+ Connects to a CrossRef Local API server instead of direct database access.
4
+ Use this when the database is on a remote server accessible via HTTP.
5
+ """
6
+
7
+ import json
8
+ import urllib.request
9
+ import urllib.parse
10
+ import urllib.error
11
+ from typing import List, Optional, Dict, Any
12
+
13
+ from .._core.models import SearchResult, Work
14
+ from .._core.config import DEFAULT_PORT
15
+
16
+ # Default URL uses SCITEX port convention
17
+ DEFAULT_API_URL = f"http://localhost:{DEFAULT_PORT}"
18
+
19
+
20
+ class RemoteClient:
21
+ """
22
+ HTTP client for CrossRef Local API server.
23
+
24
+ Provides the same interface as the local API but connects
25
+ to a remote server via HTTP.
26
+
27
+ Example:
28
+ >>> client = RemoteClient("http://localhost:31291")
29
+ >>> results = client.search(title="machine learning", limit=10)
30
+ >>> work = client.get("10.1038/nature12373")
31
+ """
32
+
33
+ def __init__(self, base_url: str = DEFAULT_API_URL, timeout: int = 30):
34
+ """
35
+ Initialize remote client.
36
+
37
+ Args:
38
+ base_url: API server URL (default: http://localhost:3333)
39
+ timeout: Request timeout in seconds
40
+ """
41
+ self.base_url = base_url.rstrip("/")
42
+ self.timeout = timeout
43
+
44
+ def _request(
45
+ self,
46
+ endpoint: str,
47
+ params: Optional[Dict[str, Any]] = None,
48
+ method: str = "GET",
49
+ data: Optional[Dict[str, Any]] = None,
50
+ ) -> Dict:
51
+ """Make HTTP request to API."""
52
+ url = f"{self.base_url}{endpoint}"
53
+ if params:
54
+ # Filter out None values
55
+ params = {k: v for k, v in params.items() if v is not None}
56
+ if params:
57
+ url = f"{url}?{urllib.parse.urlencode(params)}"
58
+
59
+ try:
60
+ req_data = None
61
+ if data is not None:
62
+ req_data = json.dumps(data).encode("utf-8")
63
+
64
+ req = urllib.request.Request(url, data=req_data, method=method)
65
+ req.add_header("Accept", "application/json")
66
+ if req_data:
67
+ req.add_header("Content-Type", "application/json")
68
+
69
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
70
+ return json.loads(response.read().decode("utf-8"))
71
+ except urllib.error.HTTPError as e:
72
+ if e.code == 404:
73
+ return None
74
+ raise ConnectionError(f"API request failed: {e.code} {e.reason}") from e
75
+ except urllib.error.URLError as e:
76
+ raise ConnectionError(
77
+ f"Cannot connect to API at {self.base_url}: {e.reason}"
78
+ ) from e
79
+
80
+ def health(self) -> Dict:
81
+ """Check API server health."""
82
+ return self._request("/health")
83
+
84
+ def info(self) -> Dict:
85
+ """Get database/API information."""
86
+ root = self._request("/")
87
+ info_data = self._request("/info")
88
+ return {
89
+ "api_url": self.base_url,
90
+ "api_version": root.get("version", "unknown"),
91
+ "status": root.get("status", "unknown"),
92
+ "mode": "remote",
93
+ "works": info_data.get("total_papers", 0) if info_data else 0,
94
+ "fts_indexed": info_data.get("fts_indexed", 0) if info_data else 0,
95
+ "citations": info_data.get("citations", 0) if info_data else 0,
96
+ }
97
+
98
+ def search(
99
+ self,
100
+ query: Optional[str] = None,
101
+ doi: Optional[str] = None,
102
+ title: Optional[str] = None,
103
+ authors: Optional[str] = None,
104
+ year: Optional[int] = None,
105
+ limit: int = 10,
106
+ offset: int = 0,
107
+ with_if: bool = False,
108
+ ) -> SearchResult:
109
+ """
110
+ Search for papers.
111
+
112
+ Args:
113
+ query: Full-text search query (searches title by default)
114
+ doi: Search by DOI
115
+ title: Search by title (explicit)
116
+ authors: Search by author name
117
+ year: Filter by publication year
118
+ limit: Maximum results (default: 10)
119
+ offset: Skip first N results for pagination
120
+ with_if: Include impact factor data (OpenAlex)
121
+
122
+ Returns:
123
+ SearchResult with matching works
124
+ """
125
+ # Use new /works endpoint with FTS5 search
126
+ search_query = query or title
127
+
128
+ params = {
129
+ "q": search_query,
130
+ "limit": limit,
131
+ "offset": offset,
132
+ "with_if": with_if,
133
+ }
134
+
135
+ data = self._request("/works", params)
136
+
137
+ if not data:
138
+ return SearchResult(works=[], total=0, query=query or "", elapsed_ms=0.0)
139
+
140
+ works = []
141
+ for item in data.get("results", []):
142
+ work = Work(
143
+ doi=item.get("doi", ""),
144
+ title=item.get("title", ""),
145
+ authors=item.get("authors", []),
146
+ year=item.get("year"),
147
+ journal=item.get("journal"),
148
+ issn=item.get("issn"),
149
+ volume=item.get("volume"),
150
+ issue=item.get("issue"),
151
+ page=item.get("page") or item.get("pages"),
152
+ abstract=item.get("abstract"),
153
+ citation_count=item.get("citation_count"),
154
+ impact_factor=item.get("impact_factor"),
155
+ impact_factor_source=item.get("impact_factor_source"),
156
+ )
157
+ works.append(work)
158
+
159
+ # Parse limit_info from response
160
+ limit_info = None
161
+ if data.get("limit_info"):
162
+ from .._core.models import LimitInfo
163
+
164
+ li = data["limit_info"]
165
+ limit_info = LimitInfo(
166
+ requested=li.get("requested", limit),
167
+ returned=li.get("returned", len(works)),
168
+ total_available=li.get("total_available", data.get("total", 0)),
169
+ capped=li.get("capped", False),
170
+ capped_reason=li.get("capped_reason"),
171
+ stage=li.get("stage", "crossref-local-remote"),
172
+ )
173
+
174
+ return SearchResult(
175
+ works=works,
176
+ total=data.get("total", len(works)),
177
+ query=query or title or doi or "",
178
+ elapsed_ms=data.get("elapsed_ms", 0.0),
179
+ limit_info=limit_info,
180
+ )
181
+
182
+ def get(self, doi: str) -> Optional[Work]:
183
+ """
184
+ Get a work by DOI.
185
+
186
+ Args:
187
+ doi: Digital Object Identifier
188
+
189
+ Returns:
190
+ Work object or None if not found
191
+ """
192
+ # Use /works/{doi} endpoint directly
193
+ data = self._request(f"/works/{doi}")
194
+ if not data or "error" in data:
195
+ return None
196
+
197
+ return Work(
198
+ doi=data.get("doi", doi),
199
+ title=data.get("title", ""),
200
+ authors=data.get("authors", []),
201
+ year=data.get("year"),
202
+ journal=data.get("journal"),
203
+ volume=data.get("volume"),
204
+ issue=data.get("issue"),
205
+ page=data.get("page"),
206
+ abstract=data.get("abstract"),
207
+ citation_count=data.get("citation_count"),
208
+ )
209
+
210
+ def get_many(self, dois: List[str]) -> List[Work]:
211
+ """
212
+ Get multiple works by DOI using batch endpoint.
213
+
214
+ Args:
215
+ dois: List of DOIs
216
+
217
+ Returns:
218
+ List of Work objects
219
+ """
220
+ # Use batch endpoint if available
221
+ try:
222
+ data = {"dois": dois}
223
+ req_data = json.dumps(data).encode("utf-8")
224
+ req = urllib.request.Request(
225
+ f"{self.base_url}/works/batch", data=req_data, method="POST"
226
+ )
227
+ req.add_header("Content-Type", "application/json")
228
+ req.add_header("Accept", "application/json")
229
+
230
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
231
+ result = json.loads(response.read().decode("utf-8"))
232
+
233
+ works = []
234
+ for item in result.get("results", []):
235
+ work = Work(
236
+ doi=item.get("doi", ""),
237
+ title=item.get("title", ""),
238
+ authors=item.get("authors", []),
239
+ year=item.get("year"),
240
+ journal=item.get("journal"),
241
+ volume=item.get("volume"),
242
+ issue=item.get("issue"),
243
+ page=item.get("page"),
244
+ abstract=item.get("abstract"),
245
+ citation_count=item.get("citation_count"),
246
+ )
247
+ works.append(work)
248
+ return works
249
+ except Exception:
250
+ # Fallback to individual lookups
251
+ works = []
252
+ for doi in dois:
253
+ work = self.get(doi)
254
+ if work:
255
+ works.append(work)
256
+ return works
257
+
258
+ def exists(self, doi: str) -> bool:
259
+ """Check if a DOI exists."""
260
+ return self.get(doi) is not None
261
+
262
+ def get_citations(self, doi: str, direction: str = "both") -> Dict:
263
+ """
264
+ Get citations for a paper (legacy endpoint).
265
+
266
+ Args:
267
+ doi: Paper DOI
268
+ direction: 'citing', 'cited_by', or 'both'
269
+
270
+ Returns:
271
+ Dict with citation information
272
+ """
273
+ params = {"doi": doi, "direction": direction}
274
+ return self._request("/api/citations/", params) or {}
275
+
276
+ def get_citing(self, doi: str, limit: int = 100) -> List[str]:
277
+ """
278
+ Get DOIs of papers that cite the given DOI.
279
+
280
+ Args:
281
+ doi: The DOI to find citations for
282
+ limit: Maximum number of citing papers to return
283
+
284
+ Returns:
285
+ List of DOIs that cite this paper
286
+ """
287
+ data = self._request(f"/citations/{doi}/citing", {"limit": limit})
288
+ if not data:
289
+ return []
290
+ return data.get("papers", [])
291
+
292
+ def get_cited(self, doi: str, limit: int = 100) -> List[str]:
293
+ """
294
+ Get DOIs of papers that the given DOI cites (references).
295
+
296
+ Args:
297
+ doi: The DOI to find references for
298
+ limit: Maximum number of referenced papers to return
299
+
300
+ Returns:
301
+ List of DOIs that this paper cites
302
+ """
303
+ data = self._request(f"/citations/{doi}/cited", {"limit": limit})
304
+ if not data:
305
+ return []
306
+ return data.get("papers", [])
307
+
308
+ def get_citation_count(self, doi: str) -> int:
309
+ """
310
+ Get the number of citations for a DOI.
311
+
312
+ Args:
313
+ doi: The DOI to count citations for
314
+
315
+ Returns:
316
+ Number of papers citing this DOI
317
+ """
318
+ data = self._request(f"/citations/{doi}/count")
319
+ if not data:
320
+ return 0
321
+ return data.get("citation_count", 0)
322
+
323
+ def get_citation_network(
324
+ self, doi: str, depth: int = 1, max_citing: int = 25, max_cited: int = 25
325
+ ) -> Dict:
326
+ """
327
+ Get citation network graph for a DOI.
328
+
329
+ Args:
330
+ doi: The DOI to build the network around
331
+ depth: How many levels of citations to include (1-3)
332
+ max_citing: Max papers citing each node to include
333
+ max_cited: Max papers each node cites to include
334
+
335
+ Returns:
336
+ Dict with nodes, edges, and stats
337
+ """
338
+ params = {
339
+ "depth": depth,
340
+ "max_citing": max_citing,
341
+ "max_cited": max_cited,
342
+ }
343
+ data = self._request(f"/citations/{doi}/network", params)
344
+ return data or {}
345
+
346
+ def get_journal(
347
+ self, issn: Optional[str] = None, name: Optional[str] = None
348
+ ) -> Dict:
349
+ """
350
+ Get journal information.
351
+
352
+ Args:
353
+ issn: Journal ISSN
354
+ name: Journal name
355
+
356
+ Returns:
357
+ Dict with journal information
358
+ """
359
+ params = {"issn": issn, "name": name}
360
+ return self._request("/api/journal/", params) or {}
361
+
362
+
363
+ # Module-level client for convenience
364
+ _client: Optional[RemoteClient] = None
365
+
366
+
367
+ def get_client(base_url: str = DEFAULT_API_URL) -> RemoteClient:
368
+ """Get or create singleton remote client."""
369
+ global _client
370
+ if _client is None or _client.base_url != base_url:
371
+ _client = RemoteClient(base_url)
372
+ return _client
373
+
374
+
375
+ def reset_client() -> None:
376
+ """Reset singleton client."""
377
+ global _client
378
+ _client = None
@@ -0,0 +1,175 @@
1
+ """Collection methods mixin for RemoteClient."""
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.parse
6
+ import urllib.error
7
+ from typing import Dict, List, Optional, Any
8
+
9
+
10
+ class CollectionsMixin:
11
+ """Mixin providing collection management methods for RemoteClient."""
12
+
13
+ def list_collections(self) -> List[Dict]:
14
+ """
15
+ List all collections.
16
+
17
+ Returns:
18
+ List of collection info dictionaries
19
+ """
20
+ data = self._request("/collections")
21
+ if not data:
22
+ return []
23
+ return data.get("collections", [])
24
+
25
+ def create_collection(
26
+ self,
27
+ name: str,
28
+ query: Optional[str] = None,
29
+ dois: Optional[List[str]] = None,
30
+ limit: int = 1000,
31
+ ) -> Dict:
32
+ """
33
+ Create a new collection from search query or DOI list.
34
+
35
+ Args:
36
+ name: Collection name
37
+ query: FTS search query (if dois not provided)
38
+ dois: Explicit list of DOIs
39
+ limit: Max papers for query mode
40
+
41
+ Returns:
42
+ Collection info dictionary
43
+ """
44
+ body = {"name": name, "limit": limit}
45
+ if query:
46
+ body["query"] = query
47
+ if dois:
48
+ body["dois"] = dois
49
+
50
+ result = self._request("/collections", method="POST", data=body)
51
+ return result or {}
52
+
53
+ def get_collection(
54
+ self,
55
+ name: str,
56
+ fields: Optional[List[str]] = None,
57
+ include_abstract: bool = False,
58
+ include_references: bool = False,
59
+ include_citations: bool = False,
60
+ year_min: Optional[int] = None,
61
+ year_max: Optional[int] = None,
62
+ journal: Optional[str] = None,
63
+ limit: Optional[int] = None,
64
+ ) -> Dict:
65
+ """
66
+ Query a collection with field filtering.
67
+
68
+ Args:
69
+ name: Collection name
70
+ fields: Explicit field list
71
+ include_abstract: Include abstracts
72
+ include_references: Include references
73
+ include_citations: Include citation counts
74
+ year_min: Filter by min year
75
+ year_max: Filter by max year
76
+ journal: Filter by journal
77
+ limit: Max results
78
+
79
+ Returns:
80
+ Dict with collection name, count, and papers
81
+ """
82
+ params = {
83
+ "include_abstract": include_abstract,
84
+ "include_references": include_references,
85
+ "include_citations": include_citations,
86
+ "year_min": year_min,
87
+ "year_max": year_max,
88
+ "journal": journal,
89
+ "limit": limit,
90
+ }
91
+ if fields:
92
+ params["fields"] = ",".join(fields)
93
+
94
+ data = self._request(f"/collections/{name}", params)
95
+ return data or {}
96
+
97
+ def get_collection_stats(self, name: str) -> Dict:
98
+ """
99
+ Get collection statistics.
100
+
101
+ Args:
102
+ name: Collection name
103
+
104
+ Returns:
105
+ Dict with year distribution, top journals, citation stats
106
+ """
107
+ data = self._request(f"/collections/{name}/stats")
108
+ return data or {}
109
+
110
+ def download_collection(
111
+ self,
112
+ name: str,
113
+ output_path: str,
114
+ format: str = "json",
115
+ fields: Optional[List[str]] = None,
116
+ ) -> str:
117
+ """
118
+ Download collection as a file.
119
+
120
+ Args:
121
+ name: Collection name
122
+ output_path: Local file path to save to
123
+ format: Export format (json, csv, bibtex, dois)
124
+ fields: Fields to include (json/csv)
125
+
126
+ Returns:
127
+ Output file path
128
+ """
129
+ params = {"format": format}
130
+ if fields:
131
+ params["fields"] = ",".join(fields)
132
+
133
+ url = f"{self.base_url}/collections/{name}/download"
134
+ if params:
135
+ url = f"{url}?{urllib.parse.urlencode(params)}"
136
+
137
+ try:
138
+ req = urllib.request.Request(url)
139
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
140
+ content = response.read()
141
+ with open(output_path, "wb") as f:
142
+ f.write(content)
143
+ return output_path
144
+ except urllib.error.HTTPError as e:
145
+ raise ConnectionError(f"Download failed: {e.code} {e.reason}") from e
146
+ except urllib.error.URLError as e:
147
+ raise ConnectionError(f"Cannot connect: {e.reason}") from e
148
+
149
+ def delete_collection(self, name: str) -> bool:
150
+ """
151
+ Delete a collection.
152
+
153
+ Args:
154
+ name: Collection name
155
+
156
+ Returns:
157
+ True if deleted
158
+ """
159
+ data = self._request(f"/collections/{name}", method="DELETE")
160
+ if not data:
161
+ return False
162
+ return data.get("deleted", False)
163
+
164
+ def collection_exists(self, name: str) -> bool:
165
+ """
166
+ Check if a collection exists.
167
+
168
+ Args:
169
+ name: Collection name
170
+
171
+ Returns:
172
+ True if exists
173
+ """
174
+ data = self._request(f"/collections/{name}/stats")
175
+ return data is not None
@@ -0,0 +1,140 @@
1
+ """FastAPI server for CrossRef Local with FTS5 search.
2
+
3
+ Modular server structure:
4
+ - routes_works.py: /works endpoints
5
+ - routes_citations.py: /citations endpoints
6
+ - routes_collections.py: /collections endpoints
7
+ - routes_compat.py: Legacy /api/* endpoints
8
+ - models.py: Pydantic response models
9
+ - middleware.py: Request middleware
10
+ """
11
+
12
+ import os
13
+
14
+ from fastapi import FastAPI
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+
17
+ from .. import __version__
18
+ from .middleware import UserContextMiddleware
19
+ from .routes_works import router as works_router
20
+ from .routes_citations import router as citations_router
21
+ from .routes_collections import router as collections_router
22
+ from .routes_compat import router as compat_router
23
+
24
+ # Create FastAPI app
25
+ app = FastAPI(
26
+ title="CrossRef Local API",
27
+ description="Fast full-text search across 167M+ scholarly works",
28
+ version=__version__,
29
+ )
30
+
31
+ # Middleware
32
+ app.add_middleware(UserContextMiddleware)
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=["*"],
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+ # Include routers
41
+ app.include_router(works_router)
42
+ app.include_router(citations_router)
43
+ app.include_router(collections_router)
44
+ app.include_router(compat_router)
45
+
46
+
47
+ @app.get("/")
48
+ def root():
49
+ """API root with endpoint information."""
50
+ return {
51
+ "name": "CrossRef Local API",
52
+ "version": __version__,
53
+ "status": "running",
54
+ "endpoints": {
55
+ "health": "/health",
56
+ "info": "/info",
57
+ "search": "/works?q=<query>",
58
+ "get_by_doi": "/works/{doi}",
59
+ "batch": "/works/batch",
60
+ "citations_citing": "/citations/{doi}/citing",
61
+ "citations_cited": "/citations/{doi}/cited",
62
+ "citations_count": "/citations/{doi}/count",
63
+ "citations_network": "/citations/{doi}/network",
64
+ "collections_list": "/collections",
65
+ "collections_create": "/collections (POST)",
66
+ "collections_get": "/collections/{name}",
67
+ "collections_stats": "/collections/{name}/stats",
68
+ "collections_download": "/collections/{name}/download",
69
+ "collections_delete": "/collections/{name} (DELETE)",
70
+ },
71
+ }
72
+
73
+
74
+ @app.get("/health")
75
+ def health():
76
+ """Health check endpoint."""
77
+ from .._core.db import get_db
78
+
79
+ db = get_db()
80
+ return {
81
+ "status": "healthy",
82
+ "database_connected": db is not None,
83
+ "database_path": str(db.db_path) if db else None,
84
+ }
85
+
86
+
87
+ @app.get("/info")
88
+ def info():
89
+ """Get database statistics."""
90
+ from .._core.db import get_db
91
+ from .models import InfoResponse
92
+
93
+ db = get_db()
94
+
95
+ row = db.fetchone("SELECT COUNT(*) as count FROM works")
96
+ work_count = row["count"] if row else 0
97
+
98
+ try:
99
+ row = db.fetchone("SELECT COUNT(*) as count FROM works_fts")
100
+ fts_count = row["count"] if row else 0
101
+ except Exception:
102
+ fts_count = 0
103
+
104
+ try:
105
+ row = db.fetchone("SELECT COUNT(*) as count FROM citations")
106
+ citation_count = row["count"] if row else 0
107
+ except Exception:
108
+ citation_count = 0
109
+
110
+ return InfoResponse(
111
+ total_papers=work_count,
112
+ fts_indexed=fts_count,
113
+ citations=citation_count,
114
+ database_path=str(db.db_path),
115
+ )
116
+
117
+
118
+ # Default port: SCITEX convention (3129X scheme)
119
+ DEFAULT_PORT = int(
120
+ os.environ.get(
121
+ "SCITEX_SCHOLAR_CROSSREF_PORT",
122
+ os.environ.get("CROSSREF_LOCAL_PORT", "31291"),
123
+ )
124
+ )
125
+ DEFAULT_HOST = os.environ.get(
126
+ "SCITEX_SCHOLAR_CROSSREF_HOST",
127
+ os.environ.get("CROSSREF_LOCAL_HOST", "0.0.0.0"),
128
+ )
129
+
130
+
131
+ def run_server(host: str = None, port: int = None):
132
+ """Run the FastAPI server."""
133
+ import uvicorn
134
+
135
+ host = host or DEFAULT_HOST
136
+ port = port or DEFAULT_PORT
137
+ uvicorn.run(app, host=host, port=port)
138
+
139
+
140
+ __all__ = ["app", "run_server", "DEFAULT_PORT", "DEFAULT_HOST"]