crossref-local 0.3.1__py3-none-any.whl → 0.4.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.
- crossref_local/__init__.py +23 -9
- crossref_local/__main__.py +0 -0
- crossref_local/aio.py +0 -0
- crossref_local/api.py +104 -29
- crossref_local/cache.py +466 -0
- crossref_local/cache_export.py +83 -0
- crossref_local/cache_viz.py +296 -0
- crossref_local/citations.py +0 -0
- crossref_local/cli.py +205 -137
- crossref_local/cli_cache.py +179 -0
- crossref_local/cli_completion.py +245 -0
- crossref_local/cli_main.py +20 -0
- crossref_local/cli_mcp.py +275 -0
- crossref_local/config.py +21 -24
- crossref_local/db.py +0 -0
- crossref_local/fts.py +0 -0
- crossref_local/impact_factor/__init__.py +0 -0
- crossref_local/impact_factor/calculator.py +0 -0
- crossref_local/impact_factor/journal_lookup.py +0 -0
- crossref_local/mcp_server.py +262 -51
- crossref_local/models.py +0 -0
- crossref_local/remote.py +5 -0
- crossref_local/server.py +7 -7
- {crossref_local-0.3.1.dist-info → crossref_local-0.4.0.dist-info}/METADATA +63 -24
- crossref_local-0.4.0.dist-info/RECORD +27 -0
- {crossref_local-0.3.1.dist-info → crossref_local-0.4.0.dist-info}/entry_points.txt +1 -1
- crossref_local-0.3.1.dist-info/RECORD +0 -20
- {crossref_local-0.3.1.dist-info → crossref_local-0.4.0.dist-info}/WHEEL +0 -0
crossref_local/cli.py
CHANGED
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import click
|
|
4
4
|
import json
|
|
5
|
-
import logging
|
|
6
5
|
import re
|
|
7
6
|
import sys
|
|
8
7
|
from typing import Optional
|
|
9
8
|
|
|
10
|
-
from . import
|
|
9
|
+
from rich.console import Console
|
|
11
10
|
|
|
12
|
-
from .
|
|
11
|
+
from . import search, get, info, __version__
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
logging.getLogger("crossref_local.impact_factor").setLevel(logging.ERROR)
|
|
13
|
+
console = Console()
|
|
16
14
|
|
|
17
15
|
|
|
18
16
|
def _strip_xml_tags(text: str) -> str:
|
|
@@ -76,30 +74,61 @@ class AliasedGroup(click.Group):
|
|
|
76
74
|
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
77
75
|
|
|
78
76
|
|
|
77
|
+
def _print_recursive_help(ctx, param, value):
|
|
78
|
+
"""Callback for --help-recursive flag."""
|
|
79
|
+
if not value or ctx.resilient_parsing:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
def _print_command_help(cmd, prefix: str, parent_ctx):
|
|
83
|
+
"""Recursively print help for a command and its subcommands."""
|
|
84
|
+
console.print(f"\n[bold cyan]━━━ {prefix} ━━━[/bold cyan]")
|
|
85
|
+
sub_ctx = click.Context(cmd, info_name=prefix.split()[-1], parent=parent_ctx)
|
|
86
|
+
console.print(cmd.get_help(sub_ctx))
|
|
87
|
+
|
|
88
|
+
if isinstance(cmd, click.Group):
|
|
89
|
+
for sub_name, sub_cmd in sorted(cmd.commands.items()):
|
|
90
|
+
_print_command_help(sub_cmd, f"{prefix} {sub_name}", sub_ctx)
|
|
91
|
+
|
|
92
|
+
# Print main help
|
|
93
|
+
console.print("[bold cyan]━━━ crossref-local ━━━[/bold cyan]")
|
|
94
|
+
console.print(ctx.get_help())
|
|
95
|
+
|
|
96
|
+
# Print all subcommands recursively
|
|
97
|
+
for name, cmd in sorted(cli.commands.items()):
|
|
98
|
+
_print_command_help(cmd, f"crossref-local {name}", ctx)
|
|
99
|
+
|
|
100
|
+
ctx.exit(0)
|
|
101
|
+
|
|
102
|
+
|
|
79
103
|
@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS)
|
|
80
104
|
@click.version_option(version=__version__, prog_name="crossref-local")
|
|
105
|
+
@click.option("--http", is_flag=True, help="Use HTTP API instead of direct database")
|
|
81
106
|
@click.option(
|
|
82
|
-
"--
|
|
107
|
+
"--api-url",
|
|
108
|
+
envvar="CROSSREF_LOCAL_API_URL",
|
|
109
|
+
help="API URL for http mode (default: auto-detect)",
|
|
83
110
|
)
|
|
84
111
|
@click.option(
|
|
85
|
-
"--
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
"--help-recursive",
|
|
113
|
+
is_flag=True,
|
|
114
|
+
is_eager=True,
|
|
115
|
+
expose_value=False,
|
|
116
|
+
callback=_print_recursive_help,
|
|
117
|
+
help="Show help for all commands recursively.",
|
|
88
118
|
)
|
|
89
119
|
@click.pass_context
|
|
90
|
-
def cli(ctx,
|
|
120
|
+
def cli(ctx, http: bool, api_url: str):
|
|
91
121
|
"""Local CrossRef database with 167M+ works and full-text search.
|
|
92
122
|
|
|
93
|
-
Supports both
|
|
123
|
+
Supports both direct database access (db mode) and HTTP API (http mode).
|
|
94
124
|
|
|
95
125
|
\b
|
|
96
|
-
|
|
126
|
+
DB mode (default if database found):
|
|
97
127
|
crossref-local search "machine learning"
|
|
98
128
|
|
|
99
129
|
\b
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
crossref-local --remote search "machine learning"
|
|
130
|
+
HTTP mode (connect to API server):
|
|
131
|
+
crossref-local --http search "machine learning"
|
|
103
132
|
"""
|
|
104
133
|
from .config import Config
|
|
105
134
|
|
|
@@ -107,8 +136,8 @@ def cli(ctx, remote: bool, api_url: str):
|
|
|
107
136
|
|
|
108
137
|
if api_url:
|
|
109
138
|
Config.set_api_url(api_url)
|
|
110
|
-
elif
|
|
111
|
-
Config.set_mode("
|
|
139
|
+
elif http:
|
|
140
|
+
Config.set_mode("http")
|
|
112
141
|
|
|
113
142
|
|
|
114
143
|
def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
|
|
@@ -117,24 +146,42 @@ def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
|
|
|
117
146
|
return cache[issn]
|
|
118
147
|
row = db.fetchone(
|
|
119
148
|
"SELECT two_year_mean_citedness FROM journals_openalex WHERE issns LIKE ?",
|
|
120
|
-
(f"%{issn}%",)
|
|
149
|
+
(f"%{issn}%",),
|
|
121
150
|
)
|
|
122
151
|
cache[issn] = row["two_year_mean_citedness"] if row else None
|
|
123
152
|
return cache[issn]
|
|
124
153
|
|
|
125
154
|
|
|
126
|
-
@cli.command("search",
|
|
155
|
+
@cli.command("search", context_settings=CONTEXT_SETTINGS)
|
|
127
156
|
@click.argument("query")
|
|
128
|
-
@click.option(
|
|
157
|
+
@click.option(
|
|
158
|
+
"-n", "--number", "limit", default=10, show_default=True, help="Number of results"
|
|
159
|
+
)
|
|
129
160
|
@click.option("-o", "--offset", default=0, help="Skip first N results")
|
|
130
161
|
@click.option("-a", "--abstracts", is_flag=True, help="Show abstracts")
|
|
131
162
|
@click.option("-A", "--authors", is_flag=True, help="Show authors")
|
|
132
|
-
@click.option(
|
|
163
|
+
@click.option(
|
|
164
|
+
"-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor"
|
|
165
|
+
)
|
|
133
166
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
134
|
-
def search_cmd(
|
|
167
|
+
def search_cmd(
|
|
168
|
+
query: str,
|
|
169
|
+
limit: int,
|
|
170
|
+
offset: int,
|
|
171
|
+
abstracts: bool,
|
|
172
|
+
authors: bool,
|
|
173
|
+
with_if: bool,
|
|
174
|
+
as_json: bool,
|
|
175
|
+
):
|
|
135
176
|
"""Search for works by title, abstract, or authors."""
|
|
136
177
|
from .db import get_db
|
|
137
|
-
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
results = search(query, limit=limit, offset=offset)
|
|
181
|
+
except ConnectionError as e:
|
|
182
|
+
click.echo(f"Error: {e}", err=True)
|
|
183
|
+
click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
|
|
184
|
+
sys.exit(1)
|
|
138
185
|
|
|
139
186
|
# Cache for fast IF lookups
|
|
140
187
|
if_cache = {}
|
|
@@ -177,13 +224,18 @@ def search_cmd(query: str, limit: int, offset: int, abstracts: bool, authors: bo
|
|
|
177
224
|
click.echo()
|
|
178
225
|
|
|
179
226
|
|
|
180
|
-
@cli.command("
|
|
227
|
+
@cli.command("search-by-doi", context_settings=CONTEXT_SETTINGS)
|
|
181
228
|
@click.argument("doi")
|
|
182
229
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
183
230
|
@click.option("--citation", is_flag=True, help="Output as citation")
|
|
184
|
-
def
|
|
185
|
-
"""
|
|
186
|
-
|
|
231
|
+
def search_by_doi_cmd(doi: str, as_json: bool, citation: bool):
|
|
232
|
+
"""Search for a work by DOI."""
|
|
233
|
+
try:
|
|
234
|
+
work = get(doi)
|
|
235
|
+
except ConnectionError as e:
|
|
236
|
+
click.echo(f"Error: {e}", err=True)
|
|
237
|
+
click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
|
|
238
|
+
sys.exit(1)
|
|
187
239
|
|
|
188
240
|
if work is None:
|
|
189
241
|
click.echo(f"DOI not found: {doi}", err=True)
|
|
@@ -203,92 +255,60 @@ def get_cmd(doi: str, as_json: bool, citation: bool):
|
|
|
203
255
|
click.echo(f"Citations: {work.citation_count}")
|
|
204
256
|
|
|
205
257
|
|
|
206
|
-
@cli.command("count", aliases=["c"], context_settings=CONTEXT_SETTINGS)
|
|
207
|
-
@click.argument("query")
|
|
208
|
-
def count_cmd(query: str):
|
|
209
|
-
"""Count matching works."""
|
|
210
|
-
n = count(query)
|
|
211
|
-
click.echo(f"{n:,}")
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
@cli.command("info", aliases=["i"], context_settings=CONTEXT_SETTINGS)
|
|
215
|
-
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
216
|
-
def info_cmd(as_json: bool):
|
|
217
|
-
"""Show database/API information."""
|
|
218
|
-
db_info = info()
|
|
219
|
-
|
|
220
|
-
if as_json:
|
|
221
|
-
click.echo(json.dumps(db_info, indent=2))
|
|
222
|
-
else:
|
|
223
|
-
mode = db_info.get("mode", "local")
|
|
224
|
-
if mode == "remote":
|
|
225
|
-
click.echo("CrossRef Local API (Remote)")
|
|
226
|
-
click.echo("-" * 40)
|
|
227
|
-
click.echo(f"API URL: {db_info.get('api_url', 'unknown')}")
|
|
228
|
-
click.echo(f"Status: {db_info.get('status', 'unknown')}")
|
|
229
|
-
else:
|
|
230
|
-
click.echo("CrossRef Local Database")
|
|
231
|
-
click.echo("-" * 40)
|
|
232
|
-
click.echo(f"Database: {db_info.get('db_path', 'unknown')}")
|
|
233
|
-
click.echo(f"Works: {db_info.get('works', 0):,}")
|
|
234
|
-
click.echo(f"FTS indexed: {db_info.get('fts_indexed', 0):,}")
|
|
235
|
-
click.echo(f"Citations: {db_info.get('citations', 0):,}")
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
@cli.command("impact-factor", aliases=["if"], context_settings=CONTEXT_SETTINGS)
|
|
239
|
-
@click.argument("journal")
|
|
240
|
-
@click.option("-y", "--year", default=2023, help="Target year")
|
|
241
|
-
@click.option("-w", "--window", default=2, help="Citation window years")
|
|
242
|
-
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
243
|
-
def impact_factor_cmd(journal: str, year: int, window: int, as_json: bool):
|
|
244
|
-
"""Calculate impact factor for a journal."""
|
|
245
|
-
with ImpactFactorCalculator() as calc:
|
|
246
|
-
result = calc.calculate_impact_factor(
|
|
247
|
-
journal_identifier=journal,
|
|
248
|
-
target_year=year,
|
|
249
|
-
window_years=window,
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
if as_json:
|
|
253
|
-
click.echo(json.dumps(result, indent=2))
|
|
254
|
-
else:
|
|
255
|
-
click.echo(f"Journal: {result['journal']}")
|
|
256
|
-
click.echo(f"Year: {result['target_year']}")
|
|
257
|
-
click.echo(f"Window: {result['window_range']}")
|
|
258
|
-
click.echo(f"Articles: {result['total_articles']:,}")
|
|
259
|
-
click.echo(f"Citations: {result['total_citations']:,}")
|
|
260
|
-
click.echo(f"Impact Factor: {result['impact_factor']:.3f}")
|
|
261
|
-
|
|
262
|
-
|
|
263
258
|
@cli.command(context_settings=CONTEXT_SETTINGS)
|
|
264
|
-
def
|
|
265
|
-
"""
|
|
259
|
+
def status():
|
|
260
|
+
"""Show status and configuration."""
|
|
266
261
|
from .config import DEFAULT_DB_PATHS, DEFAULT_API_URLS
|
|
267
262
|
import os
|
|
268
263
|
|
|
269
|
-
click.echo("CrossRef Local -
|
|
264
|
+
click.echo("CrossRef Local - Status")
|
|
270
265
|
click.echo("=" * 50)
|
|
271
266
|
click.echo()
|
|
272
267
|
|
|
273
268
|
# Check environment variables
|
|
274
269
|
click.echo("Environment Variables:")
|
|
275
|
-
|
|
276
|
-
env_api = os.environ.get("CROSSREF_LOCAL_API")
|
|
277
|
-
env_mode = os.environ.get("CROSSREF_LOCAL_MODE")
|
|
278
|
-
|
|
279
|
-
if env_db:
|
|
280
|
-
status = "OK" if os.path.exists(env_db) else "NOT FOUND"
|
|
281
|
-
click.echo(f" CROSSREF_LOCAL_DB: {env_db} ({status})")
|
|
282
|
-
else:
|
|
283
|
-
click.echo(" CROSSREF_LOCAL_DB: (not set)")
|
|
284
|
-
|
|
285
|
-
if env_api:
|
|
286
|
-
click.echo(f" CROSSREF_LOCAL_API: {env_api}")
|
|
287
|
-
else:
|
|
288
|
-
click.echo(" CROSSREF_LOCAL_API: (not set)")
|
|
270
|
+
click.echo()
|
|
289
271
|
|
|
290
|
-
|
|
291
|
-
|
|
272
|
+
env_vars = [
|
|
273
|
+
(
|
|
274
|
+
"CROSSREF_LOCAL_DB",
|
|
275
|
+
"Path to SQLite database file",
|
|
276
|
+
os.environ.get("CROSSREF_LOCAL_DB"),
|
|
277
|
+
),
|
|
278
|
+
(
|
|
279
|
+
"CROSSREF_LOCAL_API_URL",
|
|
280
|
+
"HTTP API URL (e.g., http://localhost:8333)",
|
|
281
|
+
os.environ.get("CROSSREF_LOCAL_API_URL"),
|
|
282
|
+
),
|
|
283
|
+
(
|
|
284
|
+
"CROSSREF_LOCAL_MODE",
|
|
285
|
+
"Force mode: 'db', 'http', or 'auto'",
|
|
286
|
+
os.environ.get("CROSSREF_LOCAL_MODE"),
|
|
287
|
+
),
|
|
288
|
+
(
|
|
289
|
+
"CROSSREF_LOCAL_HOST",
|
|
290
|
+
"Host for run-server-http (default: 0.0.0.0)",
|
|
291
|
+
os.environ.get("CROSSREF_LOCAL_HOST"),
|
|
292
|
+
),
|
|
293
|
+
(
|
|
294
|
+
"CROSSREF_LOCAL_PORT",
|
|
295
|
+
"Port for run-server-http (default: 8333)",
|
|
296
|
+
os.environ.get("CROSSREF_LOCAL_PORT"),
|
|
297
|
+
),
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
for var_name, description, value in env_vars:
|
|
301
|
+
if value:
|
|
302
|
+
if var_name == "CROSSREF_LOCAL_DB":
|
|
303
|
+
status = " (OK)" if os.path.exists(value) else " (NOT FOUND)"
|
|
304
|
+
else:
|
|
305
|
+
status = ""
|
|
306
|
+
click.echo(f" {var_name}={value}{status}")
|
|
307
|
+
click.echo(f" | {description}")
|
|
308
|
+
else:
|
|
309
|
+
click.echo(f" {var_name} (not set)")
|
|
310
|
+
click.echo(f" | {description}")
|
|
311
|
+
click.echo()
|
|
292
312
|
|
|
293
313
|
click.echo()
|
|
294
314
|
|
|
@@ -305,17 +325,35 @@ def setup():
|
|
|
305
325
|
|
|
306
326
|
click.echo()
|
|
307
327
|
|
|
308
|
-
# Check
|
|
309
|
-
click.echo("
|
|
328
|
+
# Check API servers
|
|
329
|
+
click.echo("API Servers:")
|
|
310
330
|
api_found = None
|
|
331
|
+
api_compatible = False
|
|
311
332
|
for url in DEFAULT_API_URLS:
|
|
312
333
|
try:
|
|
313
334
|
import urllib.request
|
|
335
|
+
import json as json_module
|
|
314
336
|
|
|
315
|
-
|
|
337
|
+
# Check root endpoint for version
|
|
338
|
+
req = urllib.request.Request(f"{url}/", method="GET")
|
|
339
|
+
req.add_header("Accept", "application/json")
|
|
316
340
|
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
317
341
|
if resp.status == 200:
|
|
318
|
-
|
|
342
|
+
data = json_module.loads(resp.read().decode())
|
|
343
|
+
server_version = data.get("version", "unknown")
|
|
344
|
+
|
|
345
|
+
# Check version compatibility
|
|
346
|
+
if server_version == __version__:
|
|
347
|
+
click.echo(f" [OK] {url} (v{server_version})")
|
|
348
|
+
api_compatible = True
|
|
349
|
+
else:
|
|
350
|
+
click.echo(
|
|
351
|
+
f" [WARN] {url} (v{server_version} != v{__version__})"
|
|
352
|
+
)
|
|
353
|
+
click.echo(
|
|
354
|
+
f" Server version mismatch - may be incompatible"
|
|
355
|
+
)
|
|
356
|
+
|
|
319
357
|
if api_found is None:
|
|
320
358
|
api_found = url
|
|
321
359
|
else:
|
|
@@ -338,59 +376,81 @@ def setup():
|
|
|
338
376
|
click.echo("Ready! Try:")
|
|
339
377
|
click.echo(' crossref-local search "machine learning"')
|
|
340
378
|
elif api_found:
|
|
341
|
-
click.echo(f"
|
|
379
|
+
click.echo(f"HTTP API available: {api_found}")
|
|
342
380
|
click.echo()
|
|
343
381
|
click.echo("Ready! Try:")
|
|
344
|
-
click.echo(' crossref-local --
|
|
382
|
+
click.echo(' crossref-local --http search "machine learning"')
|
|
345
383
|
click.echo()
|
|
346
384
|
click.echo("Or set environment:")
|
|
347
|
-
click.echo(" export CROSSREF_LOCAL_MODE=
|
|
385
|
+
click.echo(" export CROSSREF_LOCAL_MODE=http")
|
|
348
386
|
else:
|
|
349
|
-
click.echo("No database or API found!")
|
|
387
|
+
click.echo("No database or API server found!")
|
|
350
388
|
click.echo()
|
|
351
389
|
click.echo("Options:")
|
|
352
|
-
click.echo(" 1.
|
|
390
|
+
click.echo(" 1. Direct database access (db mode):")
|
|
353
391
|
click.echo(" export CROSSREF_LOCAL_DB=/path/to/crossref.db")
|
|
354
392
|
click.echo()
|
|
355
|
-
click.echo(" 2.
|
|
356
|
-
click.echo("
|
|
357
|
-
click.echo(" crossref-local --remote search 'query'")
|
|
393
|
+
click.echo(" 2. HTTP API (connect to server):")
|
|
394
|
+
click.echo(" crossref-local --http search 'query'")
|
|
358
395
|
|
|
359
396
|
|
|
360
|
-
@cli.command(context_settings=CONTEXT_SETTINGS)
|
|
397
|
+
@cli.command("run-server-mcp", context_settings=CONTEXT_SETTINGS)
|
|
361
398
|
@click.option(
|
|
362
399
|
"-t",
|
|
363
400
|
"--transport",
|
|
364
401
|
type=click.Choice(["stdio", "sse", "http"]),
|
|
365
402
|
default="stdio",
|
|
366
|
-
help="Transport protocol (
|
|
403
|
+
help="Transport protocol (http recommended for remote)",
|
|
367
404
|
)
|
|
368
|
-
@click.option(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
""
|
|
405
|
+
@click.option(
|
|
406
|
+
"--host",
|
|
407
|
+
default="localhost",
|
|
408
|
+
envvar="CROSSREF_LOCAL_MCP_HOST",
|
|
409
|
+
help="Host for HTTP/SSE transport",
|
|
410
|
+
)
|
|
411
|
+
@click.option(
|
|
412
|
+
"--port",
|
|
413
|
+
default=8082,
|
|
414
|
+
type=int,
|
|
415
|
+
envvar="CROSSREF_LOCAL_MCP_PORT",
|
|
416
|
+
help="Port for HTTP/SSE transport",
|
|
417
|
+
)
|
|
418
|
+
def serve_mcp(transport: str, host: str, port: int):
|
|
419
|
+
"""Run MCP (Model Context Protocol) server.
|
|
372
420
|
|
|
373
421
|
\b
|
|
374
|
-
|
|
422
|
+
Transports:
|
|
423
|
+
stdio - Standard I/O (default, for Claude Desktop local)
|
|
424
|
+
http - Streamable HTTP (recommended for remote/persistent)
|
|
425
|
+
sse - Server-Sent Events (deprecated as of MCP spec 2025-03-26)
|
|
426
|
+
|
|
427
|
+
\b
|
|
428
|
+
Local configuration (stdio):
|
|
375
429
|
{
|
|
376
430
|
"mcpServers": {
|
|
377
431
|
"crossref": {
|
|
378
432
|
"command": "crossref-local",
|
|
379
|
-
"args": ["
|
|
433
|
+
"args": ["run-server-mcp"]
|
|
380
434
|
}
|
|
381
435
|
}
|
|
382
436
|
}
|
|
383
437
|
|
|
384
438
|
\b
|
|
385
|
-
|
|
439
|
+
Remote configuration (http):
|
|
440
|
+
# Start server:
|
|
441
|
+
crossref-local run-server-mcp -t http --host 0.0.0.0 --port 8082
|
|
442
|
+
|
|
443
|
+
# Client config:
|
|
386
444
|
{
|
|
387
445
|
"mcpServers": {
|
|
388
|
-
"crossref": {
|
|
389
|
-
"
|
|
390
|
-
"args": ["-m", "crossref_local.mcp_server"]
|
|
446
|
+
"crossref-remote": {
|
|
447
|
+
"url": "http://your-server:8082/mcp"
|
|
391
448
|
}
|
|
392
449
|
}
|
|
393
450
|
}
|
|
451
|
+
|
|
452
|
+
\b
|
|
453
|
+
See docs/remote-deployment.md for systemd and Docker setup.
|
|
394
454
|
"""
|
|
395
455
|
try:
|
|
396
456
|
from .mcp_server import run_server
|
|
@@ -405,11 +465,19 @@ def serve(transport: str, host: str, port: int):
|
|
|
405
465
|
run_server(transport=transport, host=host, port=port)
|
|
406
466
|
|
|
407
467
|
|
|
408
|
-
@cli.command(context_settings=CONTEXT_SETTINGS)
|
|
409
|
-
@click.option(
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
468
|
+
@cli.command("run-server-http", context_settings=CONTEXT_SETTINGS)
|
|
469
|
+
@click.option(
|
|
470
|
+
"--host", default="0.0.0.0", envvar="CROSSREF_LOCAL_HOST", help="Host to bind"
|
|
471
|
+
)
|
|
472
|
+
@click.option(
|
|
473
|
+
"--port",
|
|
474
|
+
default=8333,
|
|
475
|
+
type=int,
|
|
476
|
+
envvar="CROSSREF_LOCAL_PORT",
|
|
477
|
+
help="Port to listen on",
|
|
478
|
+
)
|
|
479
|
+
def serve_http(host: str, port: int):
|
|
480
|
+
"""Run HTTP API server.
|
|
413
481
|
|
|
414
482
|
\b
|
|
415
483
|
This runs a FastAPI server that provides proper full-text search
|
|
@@ -417,13 +485,13 @@ def api(host: str, port: int):
|
|
|
417
485
|
|
|
418
486
|
\b
|
|
419
487
|
Example:
|
|
420
|
-
crossref-local
|
|
421
|
-
crossref-local
|
|
488
|
+
crossref-local run-server-http # Run on 0.0.0.0:8333
|
|
489
|
+
crossref-local run-server-http --port 8080 # Custom port
|
|
422
490
|
|
|
423
491
|
\b
|
|
424
|
-
Then
|
|
425
|
-
|
|
426
|
-
curl "http://localhost:
|
|
492
|
+
Then connect with http mode:
|
|
493
|
+
crossref-local --http search "CRISPR"
|
|
494
|
+
curl "http://localhost:8333/works?q=CRISPR&limit=10"
|
|
427
495
|
"""
|
|
428
496
|
try:
|
|
429
497
|
from .server import run_server
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""CLI commands for cache management.
|
|
2
|
+
|
|
3
|
+
This module provides cache-related CLI commands that are registered
|
|
4
|
+
with the main CLI application.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_cache_commands(cli_group):
|
|
12
|
+
"""Register cache commands with the CLI group."""
|
|
13
|
+
|
|
14
|
+
@cli_group.group()
|
|
15
|
+
def cache():
|
|
16
|
+
"""Manage paper caches for efficient querying."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@cache.command("create")
|
|
20
|
+
@click.argument("name")
|
|
21
|
+
@click.option("-q", "--query", required=True, help="FTS search query")
|
|
22
|
+
@click.option("-l", "--limit", default=1000, help="Max papers to cache")
|
|
23
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
24
|
+
def cache_create(name, query, limit, as_json):
|
|
25
|
+
"""Create a cache from search query.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
crossref-local cache create epilepsy -q "epilepsy seizure" -l 500
|
|
29
|
+
"""
|
|
30
|
+
from . import cache as cache_module
|
|
31
|
+
|
|
32
|
+
info = cache_module.create(name, query=query, limit=limit)
|
|
33
|
+
if as_json:
|
|
34
|
+
click.echo(json.dumps(info.to_dict(), indent=2))
|
|
35
|
+
else:
|
|
36
|
+
click.echo(f"Created cache: {info.name}")
|
|
37
|
+
click.echo(f" Papers: {info.paper_count}")
|
|
38
|
+
click.echo(f" Size: {info.size_bytes / 1024 / 1024:.2f} MB")
|
|
39
|
+
click.echo(f" Path: {info.path}")
|
|
40
|
+
|
|
41
|
+
@cache.command("list")
|
|
42
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
43
|
+
def cache_list(as_json):
|
|
44
|
+
"""List all available caches."""
|
|
45
|
+
from . import cache as cache_module
|
|
46
|
+
|
|
47
|
+
caches = cache_module.list_caches()
|
|
48
|
+
if as_json:
|
|
49
|
+
click.echo(json.dumps([c.to_dict() for c in caches], indent=2))
|
|
50
|
+
else:
|
|
51
|
+
if not caches:
|
|
52
|
+
click.echo("No caches found.")
|
|
53
|
+
return
|
|
54
|
+
for c in caches:
|
|
55
|
+
click.echo(
|
|
56
|
+
f"{c.name}: {c.paper_count} papers, {c.size_bytes / 1024 / 1024:.2f} MB"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@cache.command("query")
|
|
60
|
+
@click.argument("name")
|
|
61
|
+
@click.option("-f", "--fields", help="Comma-separated field list")
|
|
62
|
+
@click.option("--abstract", is_flag=True, help="Include abstracts")
|
|
63
|
+
@click.option("--refs", is_flag=True, help="Include references")
|
|
64
|
+
@click.option("--citations", is_flag=True, help="Include citation counts")
|
|
65
|
+
@click.option("--year-min", type=int, help="Minimum year filter")
|
|
66
|
+
@click.option("--year-max", type=int, help="Maximum year filter")
|
|
67
|
+
@click.option("--journal", help="Journal name filter")
|
|
68
|
+
@click.option("-l", "--limit", type=int, help="Max results")
|
|
69
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
70
|
+
def cache_query(
|
|
71
|
+
name,
|
|
72
|
+
fields,
|
|
73
|
+
abstract,
|
|
74
|
+
refs,
|
|
75
|
+
citations,
|
|
76
|
+
year_min,
|
|
77
|
+
year_max,
|
|
78
|
+
journal,
|
|
79
|
+
limit,
|
|
80
|
+
as_json,
|
|
81
|
+
):
|
|
82
|
+
"""Query cache with field filtering.
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
crossref-local cache query epilepsy -f doi,title,year
|
|
86
|
+
crossref-local cache query epilepsy --year-min 2020 --citations
|
|
87
|
+
"""
|
|
88
|
+
from . import cache as cache_module
|
|
89
|
+
|
|
90
|
+
field_list = fields.split(",") if fields else None
|
|
91
|
+
papers = cache_module.query(
|
|
92
|
+
name,
|
|
93
|
+
fields=field_list,
|
|
94
|
+
include_abstract=abstract,
|
|
95
|
+
include_references=refs,
|
|
96
|
+
include_citations=citations,
|
|
97
|
+
year_min=year_min,
|
|
98
|
+
year_max=year_max,
|
|
99
|
+
journal=journal,
|
|
100
|
+
limit=limit,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if as_json:
|
|
104
|
+
click.echo(json.dumps(papers, indent=2))
|
|
105
|
+
else:
|
|
106
|
+
click.echo(f"Found {len(papers)} papers")
|
|
107
|
+
for p in papers[:10]:
|
|
108
|
+
title = p.get("title", "No title")[:60]
|
|
109
|
+
year = p.get("year", "?")
|
|
110
|
+
click.echo(f" [{year}] {title}...")
|
|
111
|
+
if len(papers) > 10:
|
|
112
|
+
click.echo(f" ... and {len(papers) - 10} more")
|
|
113
|
+
|
|
114
|
+
@cache.command("stats")
|
|
115
|
+
@click.argument("name")
|
|
116
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
117
|
+
def cache_stats(name, as_json):
|
|
118
|
+
"""Show cache statistics."""
|
|
119
|
+
from . import cache as cache_module
|
|
120
|
+
|
|
121
|
+
stats = cache_module.stats(name)
|
|
122
|
+
if as_json:
|
|
123
|
+
click.echo(json.dumps(stats, indent=2))
|
|
124
|
+
else:
|
|
125
|
+
click.echo(f"Papers: {stats['paper_count']}")
|
|
126
|
+
yr = stats.get("year_range", {})
|
|
127
|
+
click.echo(f"Years: {yr.get('min', '?')} - {yr.get('max', '?')}")
|
|
128
|
+
click.echo(f"Abstracts: {stats['abstract_coverage']}%")
|
|
129
|
+
click.echo("\nTop journals:")
|
|
130
|
+
for j in stats.get("top_journals", [])[:5]:
|
|
131
|
+
click.echo(f" {j['journal']}: {j['count']}")
|
|
132
|
+
|
|
133
|
+
@cache.command("export")
|
|
134
|
+
@click.argument("name")
|
|
135
|
+
@click.argument("output")
|
|
136
|
+
@click.option(
|
|
137
|
+
"--format", "fmt", default="json", help="Format: json, csv, bibtex, dois"
|
|
138
|
+
)
|
|
139
|
+
@click.option("-f", "--fields", help="Comma-separated field list")
|
|
140
|
+
def cache_export(name, output, fmt, fields):
|
|
141
|
+
"""Export cache to file.
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
crossref-local cache export epilepsy papers.csv --format csv
|
|
145
|
+
crossref-local cache export epilepsy refs.bib --format bibtex
|
|
146
|
+
"""
|
|
147
|
+
from . import cache as cache_module
|
|
148
|
+
|
|
149
|
+
field_list = fields.split(",") if fields else None
|
|
150
|
+
path = cache_module.export(name, output, format=fmt, fields=field_list)
|
|
151
|
+
click.echo(f"Exported to: {path}")
|
|
152
|
+
|
|
153
|
+
@cache.command("delete")
|
|
154
|
+
@click.argument("name")
|
|
155
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation")
|
|
156
|
+
def cache_delete(name, yes):
|
|
157
|
+
"""Delete a cache."""
|
|
158
|
+
from . import cache as cache_module
|
|
159
|
+
|
|
160
|
+
if not yes:
|
|
161
|
+
if not click.confirm(f"Delete cache '{name}'?"):
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if cache_module.delete(name):
|
|
165
|
+
click.echo(f"Deleted: {name}")
|
|
166
|
+
else:
|
|
167
|
+
click.echo(f"Cache not found: {name}")
|
|
168
|
+
|
|
169
|
+
@cache.command("dois")
|
|
170
|
+
@click.argument("name")
|
|
171
|
+
def cache_dois(name):
|
|
172
|
+
"""Output DOIs from cache (one per line)."""
|
|
173
|
+
from . import cache as cache_module
|
|
174
|
+
|
|
175
|
+
dois = cache_module.query_dois(name)
|
|
176
|
+
for doi in dois:
|
|
177
|
+
click.echo(doi)
|
|
178
|
+
|
|
179
|
+
return cache
|