crossref-local 0.5.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.
- crossref_local/__init__.py +7 -1
- crossref_local/_cli/cli.py +15 -138
- crossref_local/_cli/mcp_server.py +59 -15
- crossref_local/_cli/search.py +199 -0
- crossref_local/_core/__init__.py +4 -0
- crossref_local/_core/api.py +3 -1
- crossref_local/_core/export.py +344 -0
- crossref_local/_core/fts.py +20 -1
- crossref_local/_core/models.py +109 -0
- crossref_local/_remote/base.py +25 -3
- crossref_local/_server/models.py +14 -0
- crossref_local/_server/routes_works.py +63 -13
- {crossref_local-0.5.0.dist-info → crossref_local-0.5.1.dist-info}/METADATA +1 -1
- {crossref_local-0.5.0.dist-info → crossref_local-0.5.1.dist-info}/RECORD +16 -14
- {crossref_local-0.5.0.dist-info → crossref_local-0.5.1.dist-info}/WHEEL +0 -0
- {crossref_local-0.5.0.dist-info → crossref_local-0.5.1.dist-info}/entry_points.txt +0 -0
crossref_local/__init__.py
CHANGED
|
@@ -64,7 +64,7 @@ Modules:
|
|
|
64
64
|
aio - Async versions of all API functions
|
|
65
65
|
"""
|
|
66
66
|
|
|
67
|
-
__version__ = "0.5.
|
|
67
|
+
__version__ = "0.5.1"
|
|
68
68
|
|
|
69
69
|
# Core API (from _core package)
|
|
70
70
|
from ._core import (
|
|
@@ -89,6 +89,9 @@ from ._core import (
|
|
|
89
89
|
get_cited,
|
|
90
90
|
get_citation_count,
|
|
91
91
|
CitationNetwork,
|
|
92
|
+
# Export
|
|
93
|
+
save,
|
|
94
|
+
SUPPORTED_FORMATS,
|
|
92
95
|
)
|
|
93
96
|
|
|
94
97
|
# Async API (public module)
|
|
@@ -134,6 +137,9 @@ __all__ = [
|
|
|
134
137
|
"get_cited",
|
|
135
138
|
"get_citation_count",
|
|
136
139
|
"CitationNetwork",
|
|
140
|
+
# Export/Save
|
|
141
|
+
"save",
|
|
142
|
+
"SUPPORTED_FORMATS",
|
|
137
143
|
]
|
|
138
144
|
|
|
139
145
|
|
crossref_local/_cli/cli.py
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
"""Command-line interface for crossref_local."""
|
|
2
2
|
|
|
3
|
-
import click
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
3
|
import sys
|
|
7
|
-
from typing import Optional
|
|
8
4
|
|
|
5
|
+
import click
|
|
9
6
|
from rich.console import Console
|
|
10
7
|
|
|
11
|
-
from .. import
|
|
8
|
+
from .. import __version__, info
|
|
12
9
|
|
|
13
10
|
console = Console()
|
|
14
11
|
|
|
15
12
|
|
|
16
|
-
def _strip_xml_tags(text: str) -> str:
|
|
17
|
-
"""Strip XML/JATS tags from abstract text."""
|
|
18
|
-
if not text:
|
|
19
|
-
return text
|
|
20
|
-
# Remove XML tags
|
|
21
|
-
text = re.sub(r"<[^>]+>", " ", text)
|
|
22
|
-
# Collapse multiple spaces
|
|
23
|
-
text = re.sub(r"\s+", " ", text)
|
|
24
|
-
return text.strip()
|
|
25
|
-
|
|
26
|
-
|
|
27
13
|
class AliasedGroup(click.Group):
|
|
28
14
|
"""Click group that supports command aliases."""
|
|
29
15
|
|
|
@@ -140,126 +126,20 @@ def cli(ctx, http: bool, api_url: str):
|
|
|
140
126
|
Config.set_mode("http")
|
|
141
127
|
|
|
142
128
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if issn in cache:
|
|
146
|
-
return cache[issn]
|
|
147
|
-
row = db.fetchone(
|
|
148
|
-
"SELECT two_year_mean_citedness FROM journals_openalex WHERE issns LIKE ?",
|
|
149
|
-
(f"%{issn}%",),
|
|
150
|
-
)
|
|
151
|
-
cache[issn] = row["two_year_mean_citedness"] if row else None
|
|
152
|
-
return cache[issn]
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
@cli.command("search", context_settings=CONTEXT_SETTINGS)
|
|
156
|
-
@click.argument("query")
|
|
157
|
-
@click.option(
|
|
158
|
-
"-n", "--number", "limit", default=10, show_default=True, help="Number of results"
|
|
159
|
-
)
|
|
160
|
-
@click.option("-o", "--offset", default=0, help="Skip first N results")
|
|
161
|
-
@click.option("-a", "--abstracts", is_flag=True, help="Show abstracts")
|
|
162
|
-
@click.option("-A", "--authors", is_flag=True, help="Show authors")
|
|
163
|
-
@click.option(
|
|
164
|
-
"-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor"
|
|
165
|
-
)
|
|
166
|
-
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
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
|
-
):
|
|
176
|
-
"""Search for works by title, abstract, or authors."""
|
|
177
|
-
from .._core.db import get_db
|
|
178
|
-
|
|
179
|
-
try:
|
|
180
|
-
results = search(query, limit=limit, offset=offset)
|
|
181
|
-
except ConnectionError as e:
|
|
182
|
-
click.secho(f"Error: {e}", fg="red", err=True)
|
|
183
|
-
sys.exit(1)
|
|
184
|
-
|
|
185
|
-
if_cache, db = {}, None
|
|
186
|
-
try:
|
|
187
|
-
db = get_db() if with_if else None
|
|
188
|
-
except FileNotFoundError:
|
|
189
|
-
pass # HTTP mode: IF lookup unavailable
|
|
190
|
-
|
|
191
|
-
if as_json:
|
|
192
|
-
output = {
|
|
193
|
-
"query": results.query,
|
|
194
|
-
"total": results.total,
|
|
195
|
-
"elapsed_ms": results.elapsed_ms,
|
|
196
|
-
"works": [w.to_dict() for w in results.works],
|
|
197
|
-
}
|
|
198
|
-
click.echo(json.dumps(output, indent=2))
|
|
199
|
-
else:
|
|
200
|
-
click.secho(
|
|
201
|
-
f"Found {results.total:,} matches in {results.elapsed_ms:.1f}ms\n",
|
|
202
|
-
fg="green",
|
|
203
|
-
)
|
|
204
|
-
for i, work in enumerate(results.works, start=offset + 1):
|
|
205
|
-
title = _strip_xml_tags(work.title) if work.title else "Untitled"
|
|
206
|
-
year = f"({work.year})" if work.year else ""
|
|
207
|
-
click.secho(f"{i}. {title} {year}", fg="cyan", bold=True)
|
|
208
|
-
click.echo(f" DOI: {work.doi or 'N/A'}")
|
|
209
|
-
if authors and work.authors:
|
|
210
|
-
authors_str = ", ".join(work.authors[:5])
|
|
211
|
-
if len(work.authors) > 5:
|
|
212
|
-
authors_str += f" et al. ({len(work.authors)} total)"
|
|
213
|
-
click.echo(f" Authors: {authors_str}")
|
|
214
|
-
journal_line = f" Journal: {work.journal or 'N/A'}"
|
|
215
|
-
if db and work.issn and (if_val := _get_if_fast(db, work.issn, if_cache)):
|
|
216
|
-
journal_line += f" (IF: {if_val:.2f}, OpenAlex)"
|
|
217
|
-
click.echo(journal_line)
|
|
218
|
-
if abstracts and work.abstract:
|
|
219
|
-
abstract = _strip_xml_tags(work.abstract)[:500]
|
|
220
|
-
click.echo(
|
|
221
|
-
f" Abstract: {abstract}{'...' if len(work.abstract) > 500 else ''}"
|
|
222
|
-
)
|
|
223
|
-
click.echo()
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
@cli.command("search-by-doi", context_settings=CONTEXT_SETTINGS)
|
|
227
|
-
@click.argument("doi")
|
|
228
|
-
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
229
|
-
@click.option("--citation", is_flag=True, help="Output as citation")
|
|
230
|
-
def search_by_doi_cmd(doi: str, as_json: bool, citation: bool):
|
|
231
|
-
"""Search for a work by DOI."""
|
|
232
|
-
try:
|
|
233
|
-
work = get(doi)
|
|
234
|
-
except ConnectionError as e:
|
|
235
|
-
click.echo(f"Error: {e}", err=True)
|
|
236
|
-
click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
|
|
237
|
-
sys.exit(1)
|
|
238
|
-
|
|
239
|
-
if work is None:
|
|
240
|
-
click.echo(f"DOI not found: {doi}", err=True)
|
|
241
|
-
sys.exit(1)
|
|
129
|
+
# Register search commands from search module
|
|
130
|
+
from .search import search_by_doi_cmd, search_cmd
|
|
242
131
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
elif citation:
|
|
246
|
-
click.echo(work.citation())
|
|
247
|
-
else:
|
|
248
|
-
click.echo(f"Title: {work.title}")
|
|
249
|
-
click.echo(f"Authors: {', '.join(work.authors)}")
|
|
250
|
-
click.echo(f"Year: {work.year}")
|
|
251
|
-
click.echo(f"Journal: {work.journal}")
|
|
252
|
-
click.echo(f"DOI: {work.doi}")
|
|
253
|
-
if work.citation_count:
|
|
254
|
-
click.echo(f"Citations: {work.citation_count}")
|
|
132
|
+
cli.add_command(search_cmd)
|
|
133
|
+
cli.add_command(search_by_doi_cmd)
|
|
255
134
|
|
|
256
135
|
|
|
257
136
|
@cli.command(context_settings=CONTEXT_SETTINGS)
|
|
258
137
|
def status():
|
|
259
138
|
"""Show status and configuration."""
|
|
260
|
-
from .._core.config import DEFAULT_DB_PATHS, DEFAULT_API_URLS
|
|
261
139
|
import os
|
|
262
140
|
|
|
141
|
+
from .._core.config import DEFAULT_API_URLS, DEFAULT_DB_PATHS
|
|
142
|
+
|
|
263
143
|
click.echo("CrossRef Local - Status")
|
|
264
144
|
click.echo("=" * 50)
|
|
265
145
|
click.echo()
|
|
@@ -299,10 +179,10 @@ def status():
|
|
|
299
179
|
for var_name, description, value in env_vars:
|
|
300
180
|
if value:
|
|
301
181
|
if var_name == "CROSSREF_LOCAL_DB":
|
|
302
|
-
|
|
182
|
+
stat = " (OK)" if os.path.exists(value) else " (NOT FOUND)"
|
|
303
183
|
else:
|
|
304
|
-
|
|
305
|
-
click.echo(f" {var_name}={value}{
|
|
184
|
+
stat = ""
|
|
185
|
+
click.echo(f" {var_name}={value}{stat}")
|
|
306
186
|
click.echo(f" | {description}")
|
|
307
187
|
else:
|
|
308
188
|
click.echo(f" {var_name} (not set)")
|
|
@@ -327,11 +207,10 @@ def status():
|
|
|
327
207
|
# Check API servers
|
|
328
208
|
click.echo("API Servers:")
|
|
329
209
|
api_found = None
|
|
330
|
-
api_compatible = False
|
|
331
210
|
for url in DEFAULT_API_URLS:
|
|
332
211
|
try:
|
|
333
|
-
import urllib.request
|
|
334
212
|
import json as json_module
|
|
213
|
+
import urllib.request
|
|
335
214
|
|
|
336
215
|
# Check root endpoint for version
|
|
337
216
|
req = urllib.request.Request(f"{url}/", method="GET")
|
|
@@ -344,13 +223,12 @@ def status():
|
|
|
344
223
|
# Check version compatibility
|
|
345
224
|
if server_version == __version__:
|
|
346
225
|
click.echo(f" [OK] {url} (v{server_version})")
|
|
347
|
-
api_compatible = True
|
|
348
226
|
else:
|
|
349
227
|
click.echo(
|
|
350
228
|
f" [WARN] {url} (v{server_version} != v{__version__})"
|
|
351
229
|
)
|
|
352
230
|
click.echo(
|
|
353
|
-
|
|
231
|
+
" Server version mismatch - may be incompatible"
|
|
354
232
|
)
|
|
355
233
|
|
|
356
234
|
if api_found is None:
|
|
@@ -442,7 +320,7 @@ def relay(host: str, port: int):
|
|
|
442
320
|
curl "http://localhost:8333/works?q=CRISPR&limit=10"
|
|
443
321
|
"""
|
|
444
322
|
try:
|
|
445
|
-
from
|
|
323
|
+
from .._server import run_server
|
|
446
324
|
except ImportError:
|
|
447
325
|
click.echo(
|
|
448
326
|
"API server requires fastapi and uvicorn. Install with:\n"
|
|
@@ -451,7 +329,7 @@ def relay(host: str, port: int):
|
|
|
451
329
|
)
|
|
452
330
|
sys.exit(1)
|
|
453
331
|
|
|
454
|
-
from
|
|
332
|
+
from .._server import DEFAULT_HOST, DEFAULT_PORT
|
|
455
333
|
|
|
456
334
|
host = host or DEFAULT_HOST
|
|
457
335
|
port = port or DEFAULT_PORT
|
|
@@ -485,7 +363,6 @@ def list_apis(verbose, max_depth, as_json):
|
|
|
485
363
|
"""List Python APIs (alias for: scitex introspect api crossref_local)."""
|
|
486
364
|
try:
|
|
487
365
|
from scitex.cli.introspect import api
|
|
488
|
-
import click
|
|
489
366
|
|
|
490
367
|
ctx = click.Context(api)
|
|
491
368
|
ctx.invoke(
|
|
@@ -35,6 +35,8 @@ def search(
|
|
|
35
35
|
limit: int = 10,
|
|
36
36
|
offset: int = 0,
|
|
37
37
|
with_abstracts: bool = False,
|
|
38
|
+
save_path: str | None = None,
|
|
39
|
+
save_format: str = "json",
|
|
38
40
|
) -> str:
|
|
39
41
|
"""Search for academic works by title, abstract, or authors.
|
|
40
42
|
|
|
@@ -43,9 +45,11 @@ def search(
|
|
|
43
45
|
|
|
44
46
|
Args:
|
|
45
47
|
query: Search query (e.g., "machine learning", "CRISPR", "neural network AND hippocampus")
|
|
46
|
-
limit: Maximum number of results to return (default: 10
|
|
48
|
+
limit: Maximum number of results to return (default: 10)
|
|
47
49
|
offset: Skip first N results for pagination (default: 0)
|
|
48
50
|
with_abstracts: Include abstracts in results (default: False)
|
|
51
|
+
save_path: Optional file path to save results (e.g., "results.json", "papers.bib")
|
|
52
|
+
save_format: Output format for save_path: "text", "json", or "bibtex" (default: "json")
|
|
49
53
|
|
|
50
54
|
Returns:
|
|
51
55
|
JSON string with search results including total count and matching works.
|
|
@@ -54,8 +58,21 @@ def search(
|
|
|
54
58
|
search("machine learning")
|
|
55
59
|
search("CRISPR", limit=20)
|
|
56
60
|
search("neural network AND memory", with_abstracts=True)
|
|
61
|
+
search("epilepsy", save_path="epilepsy.bib", save_format="bibtex")
|
|
57
62
|
"""
|
|
58
|
-
results = _search(query, limit=
|
|
63
|
+
results = _search(query, limit=limit, offset=offset)
|
|
64
|
+
|
|
65
|
+
# Save to file if requested
|
|
66
|
+
saved_path = None
|
|
67
|
+
if save_path:
|
|
68
|
+
from .._core.export import save as _save
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
saved_path = _save(
|
|
72
|
+
results, save_path, format=save_format, include_abstract=with_abstracts
|
|
73
|
+
)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return json.dumps({"error": f"Failed to save: {e}"})
|
|
59
76
|
|
|
60
77
|
works_data = []
|
|
61
78
|
for work in results.works:
|
|
@@ -70,25 +87,34 @@ def search(
|
|
|
70
87
|
work_dict["abstract"] = work.abstract
|
|
71
88
|
works_data.append(work_dict)
|
|
72
89
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
result = {
|
|
91
|
+
"query": results.query,
|
|
92
|
+
"total": results.total,
|
|
93
|
+
"returned": len(works_data),
|
|
94
|
+
"elapsed_ms": round(results.elapsed_ms, 2),
|
|
95
|
+
"works": works_data,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if saved_path:
|
|
99
|
+
result["saved_to"] = saved_path
|
|
100
|
+
|
|
101
|
+
return json.dumps(result, indent=2)
|
|
83
102
|
|
|
84
103
|
|
|
85
104
|
@mcp.tool()
|
|
86
|
-
def search_by_doi(
|
|
105
|
+
def search_by_doi(
|
|
106
|
+
doi: str,
|
|
107
|
+
as_citation: bool = False,
|
|
108
|
+
save_path: str | None = None,
|
|
109
|
+
save_format: str = "json",
|
|
110
|
+
) -> str:
|
|
87
111
|
"""Get detailed information about a work by DOI.
|
|
88
112
|
|
|
89
113
|
Args:
|
|
90
114
|
doi: Digital Object Identifier (e.g., "10.1038/nature12373")
|
|
91
115
|
as_citation: Return formatted citation instead of full metadata
|
|
116
|
+
save_path: Optional file path to save result (e.g., "paper.json", "paper.bib")
|
|
117
|
+
save_format: Output format for save_path: "text", "json", or "bibtex" (default: "json")
|
|
92
118
|
|
|
93
119
|
Returns:
|
|
94
120
|
JSON string with work metadata, or formatted citation string.
|
|
@@ -96,16 +122,34 @@ def search_by_doi(doi: str, as_citation: bool = False) -> str:
|
|
|
96
122
|
Examples:
|
|
97
123
|
search_by_doi("10.1038/nature12373")
|
|
98
124
|
search_by_doi("10.1126/science.aax0758", as_citation=True)
|
|
125
|
+
search_by_doi("10.1038/nature12373", save_path="paper.bib", save_format="bibtex")
|
|
99
126
|
"""
|
|
100
127
|
work = _get(doi)
|
|
101
128
|
|
|
102
129
|
if work is None:
|
|
103
130
|
return json.dumps({"error": f"DOI not found: {doi}"})
|
|
104
131
|
|
|
132
|
+
# Save to file if requested
|
|
133
|
+
saved_path = None
|
|
134
|
+
if save_path:
|
|
135
|
+
from .._core.export import save as _save
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
saved_path = _save(work, save_path, format=save_format)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return json.dumps({"error": f"Failed to save: {e}"})
|
|
141
|
+
|
|
105
142
|
if as_citation:
|
|
106
|
-
|
|
143
|
+
result = work.citation()
|
|
144
|
+
if saved_path:
|
|
145
|
+
result += f"\n\n(Saved to: {saved_path})"
|
|
146
|
+
return result
|
|
107
147
|
|
|
108
|
-
|
|
148
|
+
result = work.to_dict()
|
|
149
|
+
if saved_path:
|
|
150
|
+
result["saved_to"] = saved_path
|
|
151
|
+
|
|
152
|
+
return json.dumps(result, indent=2)
|
|
109
153
|
|
|
110
154
|
|
|
111
155
|
@mcp.tool()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Search commands for crossref-local CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from .. import get, search
|
|
12
|
+
from .._core.export import save as _save
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_xml_tags(text: str) -> str:
|
|
20
|
+
"""Strip XML/JATS tags from abstract text."""
|
|
21
|
+
if not text:
|
|
22
|
+
return text
|
|
23
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
24
|
+
text = re.sub(r"\s+", " ", text)
|
|
25
|
+
return text.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
|
|
29
|
+
"""Fast IF lookup from OpenAlex data."""
|
|
30
|
+
if issn in cache:
|
|
31
|
+
return cache[issn]
|
|
32
|
+
q = "SELECT two_year_mean_citedness FROM journals_openalex WHERE issns LIKE ?"
|
|
33
|
+
row = db.fetchone(q, (f"%{issn}%",))
|
|
34
|
+
cache[issn] = row["two_year_mean_citedness"] if row else None
|
|
35
|
+
return cache[issn]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.command("search", context_settings=CONTEXT_SETTINGS)
|
|
39
|
+
@click.argument("query")
|
|
40
|
+
@click.option(
|
|
41
|
+
"-n", "--number", "limit", default=10, show_default=True, help="Number of results"
|
|
42
|
+
)
|
|
43
|
+
@click.option("-o", "--offset", default=0, help="Skip first N results")
|
|
44
|
+
@click.option("-a", "--abstracts", is_flag=True, help="Show abstracts")
|
|
45
|
+
@click.option("-A", "--authors", is_flag=True, help="Show authors")
|
|
46
|
+
@click.option(
|
|
47
|
+
"-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor"
|
|
48
|
+
)
|
|
49
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
50
|
+
@click.option(
|
|
51
|
+
"--save",
|
|
52
|
+
"save_path",
|
|
53
|
+
type=click.Path(),
|
|
54
|
+
help="Save results to file",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--format",
|
|
58
|
+
"save_format",
|
|
59
|
+
type=click.Choice(["text", "json", "bibtex"]),
|
|
60
|
+
default="json",
|
|
61
|
+
help="Output format for --save (default: json)",
|
|
62
|
+
)
|
|
63
|
+
def search_cmd(
|
|
64
|
+
query: str,
|
|
65
|
+
limit: int,
|
|
66
|
+
offset: int,
|
|
67
|
+
abstracts: bool,
|
|
68
|
+
authors: bool,
|
|
69
|
+
with_if: bool,
|
|
70
|
+
as_json: bool,
|
|
71
|
+
save_path: Optional[str],
|
|
72
|
+
save_format: str,
|
|
73
|
+
):
|
|
74
|
+
"""Search for works by title, abstract, or authors."""
|
|
75
|
+
from .._core.config import Config
|
|
76
|
+
from .._core.db import get_db
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
results = search(query, limit=limit, offset=offset, with_if=with_if)
|
|
80
|
+
except ConnectionError as e:
|
|
81
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
# Local IF lookup only in DB mode (HTTP gets IF from API)
|
|
85
|
+
if_cache, db = {}, None
|
|
86
|
+
if with_if and Config.get_mode() != "http":
|
|
87
|
+
try:
|
|
88
|
+
db = get_db()
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Save to file if requested
|
|
93
|
+
if save_path:
|
|
94
|
+
try:
|
|
95
|
+
saved = _save(
|
|
96
|
+
results, save_path, format=save_format, include_abstract=abstracts
|
|
97
|
+
)
|
|
98
|
+
click.secho(
|
|
99
|
+
f"Saved {len(results)} results to {saved}", fg="green", err=True
|
|
100
|
+
)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
click.secho(f"Error saving: {e}", fg="red", err=True)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
if as_json:
|
|
106
|
+
output = {
|
|
107
|
+
"query": results.query,
|
|
108
|
+
"total": results.total,
|
|
109
|
+
"elapsed_ms": results.elapsed_ms,
|
|
110
|
+
"works": [w.to_dict() for w in results.works],
|
|
111
|
+
}
|
|
112
|
+
click.echo(json.dumps(output, indent=2))
|
|
113
|
+
else:
|
|
114
|
+
click.secho(
|
|
115
|
+
f"Found {results.total:,} matches in {results.elapsed_ms:.1f}ms\n",
|
|
116
|
+
fg="green",
|
|
117
|
+
)
|
|
118
|
+
for i, work in enumerate(results.works, start=offset + 1):
|
|
119
|
+
title = _strip_xml_tags(work.title) if work.title else "Untitled"
|
|
120
|
+
year = f"({work.year})" if work.year else ""
|
|
121
|
+
click.secho(f"{i}. {title} {year}", fg="cyan", bold=True)
|
|
122
|
+
click.echo(f" DOI: {work.doi or 'N/A'}")
|
|
123
|
+
if authors and work.authors:
|
|
124
|
+
authors_str = ", ".join(work.authors[:5])
|
|
125
|
+
if len(work.authors) > 5:
|
|
126
|
+
authors_str += f" et al. ({len(work.authors)} total)"
|
|
127
|
+
click.echo(f" Authors: {authors_str}")
|
|
128
|
+
journal_line = f" Journal: {work.journal or 'N/A'}"
|
|
129
|
+
if_val = work.impact_factor or (
|
|
130
|
+
db and work.issn and _get_if_fast(db, work.issn, if_cache)
|
|
131
|
+
)
|
|
132
|
+
if if_val:
|
|
133
|
+
journal_line += f" (IF: {if_val:.2f}, OpenAlex)"
|
|
134
|
+
click.echo(journal_line)
|
|
135
|
+
if abstracts and work.abstract:
|
|
136
|
+
abstract = _strip_xml_tags(work.abstract)[:500]
|
|
137
|
+
click.echo(
|
|
138
|
+
f" Abstract: {abstract}{'...' if len(work.abstract) > 500 else ''}"
|
|
139
|
+
)
|
|
140
|
+
click.echo()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@click.command("search-by-doi", context_settings=CONTEXT_SETTINGS)
|
|
144
|
+
@click.argument("doi")
|
|
145
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
146
|
+
@click.option("--citation", is_flag=True, help="Output as citation")
|
|
147
|
+
@click.option(
|
|
148
|
+
"--save",
|
|
149
|
+
"save_path",
|
|
150
|
+
type=click.Path(),
|
|
151
|
+
help="Save result to file",
|
|
152
|
+
)
|
|
153
|
+
@click.option(
|
|
154
|
+
"--format",
|
|
155
|
+
"save_format",
|
|
156
|
+
type=click.Choice(["text", "json", "bibtex"]),
|
|
157
|
+
default="json",
|
|
158
|
+
help="Output format for --save (default: json)",
|
|
159
|
+
)
|
|
160
|
+
def search_by_doi_cmd(
|
|
161
|
+
doi: str,
|
|
162
|
+
as_json: bool,
|
|
163
|
+
citation: bool,
|
|
164
|
+
save_path: Optional[str],
|
|
165
|
+
save_format: str,
|
|
166
|
+
):
|
|
167
|
+
"""Search for a work by DOI."""
|
|
168
|
+
try:
|
|
169
|
+
work = get(doi)
|
|
170
|
+
except ConnectionError as e:
|
|
171
|
+
click.echo(f"Error: {e}", err=True)
|
|
172
|
+
click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
if work is None:
|
|
176
|
+
click.echo(f"DOI not found: {doi}", err=True)
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
|
|
179
|
+
# Save to file if requested
|
|
180
|
+
if save_path:
|
|
181
|
+
try:
|
|
182
|
+
saved = _save(work, save_path, format=save_format)
|
|
183
|
+
click.secho(f"Saved to {saved}", fg="green", err=True)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
click.secho(f"Error saving: {e}", fg="red", err=True)
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
if as_json:
|
|
189
|
+
click.echo(json.dumps(work.to_dict(), indent=2))
|
|
190
|
+
elif citation:
|
|
191
|
+
click.echo(work.citation())
|
|
192
|
+
else:
|
|
193
|
+
click.echo(f"Title: {work.title}")
|
|
194
|
+
click.echo(f"Authors: {', '.join(work.authors)}")
|
|
195
|
+
click.echo(f"Year: {work.year}")
|
|
196
|
+
click.echo(f"Journal: {work.journal}")
|
|
197
|
+
click.echo(f"DOI: {work.doi}")
|
|
198
|
+
if work.citation_count:
|
|
199
|
+
click.echo(f"Citations: {work.citation_count}")
|
crossref_local/_core/__init__.py
CHANGED
|
@@ -23,6 +23,7 @@ from .citations import (
|
|
|
23
23
|
)
|
|
24
24
|
from .config import Config
|
|
25
25
|
from .db import Database, close_db, get_db
|
|
26
|
+
from .export import SUPPORTED_FORMATS, save
|
|
26
27
|
from .models import SearchResult, Work
|
|
27
28
|
|
|
28
29
|
__all__ = [
|
|
@@ -53,6 +54,9 @@ __all__ = [
|
|
|
53
54
|
"close_db",
|
|
54
55
|
# Config
|
|
55
56
|
"Config",
|
|
57
|
+
# Export
|
|
58
|
+
"save",
|
|
59
|
+
"SUPPORTED_FORMATS",
|
|
56
60
|
]
|
|
57
61
|
|
|
58
62
|
# EOF
|
crossref_local/_core/api.py
CHANGED
|
@@ -48,6 +48,7 @@ def search(
|
|
|
48
48
|
query: str,
|
|
49
49
|
limit: int = 10,
|
|
50
50
|
offset: int = 0,
|
|
51
|
+
with_if: bool = False,
|
|
51
52
|
) -> SearchResult:
|
|
52
53
|
"""
|
|
53
54
|
Full-text search across works.
|
|
@@ -58,6 +59,7 @@ def search(
|
|
|
58
59
|
query: Search query (supports FTS5 syntax)
|
|
59
60
|
limit: Maximum results to return
|
|
60
61
|
offset: Skip first N results (for pagination)
|
|
62
|
+
with_if: Include impact factor data (OpenAlex)
|
|
61
63
|
|
|
62
64
|
Returns:
|
|
63
65
|
SearchResult with matching works
|
|
@@ -69,7 +71,7 @@ def search(
|
|
|
69
71
|
"""
|
|
70
72
|
if Config.get_mode() == "http":
|
|
71
73
|
client = _get_http_client()
|
|
72
|
-
return client.search(query=query, limit=limit, offset=offset)
|
|
74
|
+
return client.search(query=query, limit=limit, offset=offset, with_if=with_if)
|
|
73
75
|
return fts.search(query, limit, offset)
|
|
74
76
|
|
|
75
77
|
|