pyzotero 1.7.6__py3-none-any.whl → 1.9.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.
- pyzotero/__init__.py +60 -0
- pyzotero/_client.py +1402 -0
- pyzotero/_decorators.py +195 -0
- pyzotero/_search.py +190 -0
- pyzotero/_upload.py +241 -0
- pyzotero/_utils.py +86 -0
- pyzotero/cli.py +789 -4
- pyzotero/errors.py +185 -0
- pyzotero/filetransport.py +2 -2
- pyzotero/semantic_scholar.py +441 -0
- pyzotero/zotero.py +62 -2035
- pyzotero/zotero_errors.py +53 -136
- {pyzotero-1.7.6.dist-info → pyzotero-1.9.0.dist-info}/METADATA +3 -3
- pyzotero-1.9.0.dist-info/RECORD +16 -0
- pyzotero-1.7.6.dist-info/RECORD +0 -9
- {pyzotero-1.7.6.dist-info → pyzotero-1.9.0.dist-info}/WHEEL +0 -0
- {pyzotero-1.7.6.dist-info → pyzotero-1.9.0.dist-info}/entry_points.txt +0 -0
pyzotero/cli.py
CHANGED
|
@@ -3,10 +3,20 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
-
import click
|
|
6
|
+
import click # ty:ignore[unresolved-import]
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
9
9
|
from pyzotero import __version__, zotero
|
|
10
|
+
from pyzotero.semantic_scholar import (
|
|
11
|
+
PaperNotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
SemanticScholarError,
|
|
14
|
+
filter_by_citations,
|
|
15
|
+
get_citations,
|
|
16
|
+
get_recommendations,
|
|
17
|
+
get_references,
|
|
18
|
+
search_papers,
|
|
19
|
+
)
|
|
10
20
|
from pyzotero.zotero import chunks
|
|
11
21
|
|
|
12
22
|
|
|
@@ -73,12 +83,23 @@ def main(ctx, locale):
|
|
|
73
83
|
"--collection",
|
|
74
84
|
help="Filter by collection key (returns only items in this collection)",
|
|
75
85
|
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--tag",
|
|
88
|
+
multiple=True,
|
|
89
|
+
help="Filter by tag (can be specified multiple times for AND search)",
|
|
90
|
+
)
|
|
76
91
|
@click.option(
|
|
77
92
|
"--limit",
|
|
78
93
|
type=int,
|
|
79
94
|
default=1000000,
|
|
80
95
|
help="Maximum number of results to return (default: 1000000)",
|
|
81
96
|
)
|
|
97
|
+
@click.option(
|
|
98
|
+
"--offset",
|
|
99
|
+
type=int,
|
|
100
|
+
default=0,
|
|
101
|
+
help="Number of results to skip for pagination (default: 0)",
|
|
102
|
+
)
|
|
82
103
|
@click.option(
|
|
83
104
|
"--json",
|
|
84
105
|
"output_json",
|
|
@@ -86,7 +107,7 @@ def main(ctx, locale):
|
|
|
86
107
|
help="Output results as JSON",
|
|
87
108
|
)
|
|
88
109
|
@click.pass_context
|
|
89
|
-
def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # noqa: PLR0912, PLR0915
|
|
110
|
+
def search(ctx, query, fulltext, itemtype, collection, tag, limit, offset, output_json): # noqa: PLR0912, PLR0915
|
|
90
111
|
"""Search local Zotero library.
|
|
91
112
|
|
|
92
113
|
By default, searches top-level items in titles and metadata.
|
|
@@ -106,6 +127,10 @@ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # n
|
|
|
106
127
|
|
|
107
128
|
pyzotero search -q "climate" --json
|
|
108
129
|
|
|
130
|
+
pyzotero search -q "topic" --limit 20 --offset 20 --json
|
|
131
|
+
|
|
132
|
+
pyzotero search -q "topic" --tag "climate" --tag "adaptation" --json
|
|
133
|
+
|
|
109
134
|
"""
|
|
110
135
|
try:
|
|
111
136
|
locale = ctx.obj.get("locale", "en-US")
|
|
@@ -114,6 +139,9 @@ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # n
|
|
|
114
139
|
# Build query parameters
|
|
115
140
|
params = {"limit": limit}
|
|
116
141
|
|
|
142
|
+
if offset > 0:
|
|
143
|
+
params["start"] = offset
|
|
144
|
+
|
|
117
145
|
if query:
|
|
118
146
|
params["q"] = query
|
|
119
147
|
|
|
@@ -124,6 +152,10 @@ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # n
|
|
|
124
152
|
# Join multiple item types with || for OR search
|
|
125
153
|
params["itemType"] = " || ".join(itemtype)
|
|
126
154
|
|
|
155
|
+
if tag:
|
|
156
|
+
# Multiple tags are passed as a list for AND search
|
|
157
|
+
params["tag"] = list(tag)
|
|
158
|
+
|
|
127
159
|
# Execute search
|
|
128
160
|
# When fulltext is enabled, use items() or collection_items() to get both
|
|
129
161
|
# top-level items and attachments. Otherwise use top() or collection_items_top()
|
|
@@ -310,7 +342,7 @@ def listcollections(ctx, limit):
|
|
|
310
342
|
key = data.get("key", "")
|
|
311
343
|
name = data.get("name", "")
|
|
312
344
|
if key:
|
|
313
|
-
collection_map[key] = name
|
|
345
|
+
collection_map[key] = name or None
|
|
314
346
|
|
|
315
347
|
# Build JSON output
|
|
316
348
|
output = []
|
|
@@ -325,7 +357,7 @@ def listcollections(ctx, limit):
|
|
|
325
357
|
|
|
326
358
|
collection_obj = {
|
|
327
359
|
"id": key,
|
|
328
|
-
"name": name
|
|
360
|
+
"name": name or None,
|
|
329
361
|
"items": num_items,
|
|
330
362
|
}
|
|
331
363
|
|
|
@@ -419,6 +451,254 @@ def test(ctx):
|
|
|
419
451
|
sys.exit(1)
|
|
420
452
|
|
|
421
453
|
|
|
454
|
+
@main.command()
|
|
455
|
+
@click.argument("key")
|
|
456
|
+
@click.option(
|
|
457
|
+
"--json",
|
|
458
|
+
"output_json",
|
|
459
|
+
is_flag=True,
|
|
460
|
+
help="Output results as JSON",
|
|
461
|
+
)
|
|
462
|
+
@click.pass_context
|
|
463
|
+
def item(ctx, key, output_json):
|
|
464
|
+
"""Get a single item by its key.
|
|
465
|
+
|
|
466
|
+
Returns full item data for the specified Zotero item key.
|
|
467
|
+
|
|
468
|
+
Examples:
|
|
469
|
+
pyzotero item ABC123
|
|
470
|
+
|
|
471
|
+
pyzotero item ABC123 --json
|
|
472
|
+
|
|
473
|
+
"""
|
|
474
|
+
try:
|
|
475
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
476
|
+
zot = _get_zotero_client(locale)
|
|
477
|
+
|
|
478
|
+
# Fetch the item
|
|
479
|
+
result = zot.item(key)
|
|
480
|
+
|
|
481
|
+
if not result:
|
|
482
|
+
if output_json:
|
|
483
|
+
click.echo(json.dumps(None))
|
|
484
|
+
else:
|
|
485
|
+
click.echo(f"Item not found: {key}")
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
data = result.get("data", {})
|
|
489
|
+
|
|
490
|
+
if output_json:
|
|
491
|
+
click.echo(json.dumps(result, indent=2))
|
|
492
|
+
else:
|
|
493
|
+
title = data.get("title", "No title")
|
|
494
|
+
item_type = data.get("itemType", "Unknown")
|
|
495
|
+
date = data.get("date", "No date")
|
|
496
|
+
item_key = data.get("key", "")
|
|
497
|
+
doi = data.get("DOI", "")
|
|
498
|
+
url = data.get("url", "")
|
|
499
|
+
|
|
500
|
+
# Format creators
|
|
501
|
+
creators = data.get("creators", [])
|
|
502
|
+
creator_names = []
|
|
503
|
+
for creator in creators:
|
|
504
|
+
if "lastName" in creator:
|
|
505
|
+
if "firstName" in creator:
|
|
506
|
+
creator_names.append(
|
|
507
|
+
f"{creator['firstName']} {creator['lastName']}"
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
creator_names.append(creator["lastName"])
|
|
511
|
+
elif "name" in creator:
|
|
512
|
+
creator_names.append(creator["name"])
|
|
513
|
+
|
|
514
|
+
authors_str = ", ".join(creator_names) if creator_names else "No authors"
|
|
515
|
+
|
|
516
|
+
click.echo(f"[{item_type}] {title}")
|
|
517
|
+
click.echo(f"Authors: {authors_str}")
|
|
518
|
+
click.echo(f"Date: {date}")
|
|
519
|
+
click.echo(f"DOI: {doi}")
|
|
520
|
+
click.echo(f"URL: {url}")
|
|
521
|
+
click.echo(f"Key: {item_key}")
|
|
522
|
+
|
|
523
|
+
except Exception as e:
|
|
524
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
525
|
+
sys.exit(1)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@main.command()
|
|
529
|
+
@click.argument("key")
|
|
530
|
+
@click.option(
|
|
531
|
+
"--json",
|
|
532
|
+
"output_json",
|
|
533
|
+
is_flag=True,
|
|
534
|
+
help="Output results as JSON",
|
|
535
|
+
)
|
|
536
|
+
@click.pass_context
|
|
537
|
+
def children(ctx, key, output_json):
|
|
538
|
+
"""Get child items (attachments, notes) of a specific item.
|
|
539
|
+
|
|
540
|
+
Returns all child items for the specified Zotero item key.
|
|
541
|
+
Useful for finding PDF attachments without the N+1 overhead during search.
|
|
542
|
+
|
|
543
|
+
Examples:
|
|
544
|
+
pyzotero children ABC123
|
|
545
|
+
|
|
546
|
+
pyzotero children ABC123 --json
|
|
547
|
+
|
|
548
|
+
"""
|
|
549
|
+
try:
|
|
550
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
551
|
+
zot = _get_zotero_client(locale)
|
|
552
|
+
|
|
553
|
+
# Fetch children
|
|
554
|
+
results = zot.children(key)
|
|
555
|
+
|
|
556
|
+
if not results:
|
|
557
|
+
if output_json:
|
|
558
|
+
click.echo(json.dumps([]))
|
|
559
|
+
else:
|
|
560
|
+
click.echo(f"No children found for item: {key}")
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
if output_json:
|
|
564
|
+
click.echo(json.dumps(results, indent=2))
|
|
565
|
+
else:
|
|
566
|
+
click.echo(f"\nFound {len(results)} child items:\n")
|
|
567
|
+
for idx, child in enumerate(results, 1):
|
|
568
|
+
data = child.get("data", {})
|
|
569
|
+
item_type = data.get("itemType", "Unknown")
|
|
570
|
+
child_key = data.get("key", "")
|
|
571
|
+
title = data.get("title", data.get("note", "No title")[:50] + "...")
|
|
572
|
+
content_type = data.get("contentType", "")
|
|
573
|
+
|
|
574
|
+
click.echo(f"{idx}. [{item_type}] {title}")
|
|
575
|
+
click.echo(f" Key: {child_key}")
|
|
576
|
+
if content_type:
|
|
577
|
+
click.echo(f" Content-Type: {content_type}")
|
|
578
|
+
|
|
579
|
+
# Show file URL for attachments
|
|
580
|
+
file_url = child.get("links", {}).get("enclosure", {}).get("href", "")
|
|
581
|
+
if file_url:
|
|
582
|
+
click.echo(f" File: {file_url}")
|
|
583
|
+
click.echo()
|
|
584
|
+
|
|
585
|
+
except Exception as e:
|
|
586
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
587
|
+
sys.exit(1)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@main.command()
|
|
591
|
+
@click.option(
|
|
592
|
+
"--collection",
|
|
593
|
+
help="Filter tags to a specific collection key",
|
|
594
|
+
)
|
|
595
|
+
@click.option(
|
|
596
|
+
"--json",
|
|
597
|
+
"output_json",
|
|
598
|
+
is_flag=True,
|
|
599
|
+
help="Output results as JSON",
|
|
600
|
+
)
|
|
601
|
+
@click.pass_context
|
|
602
|
+
def tags(ctx, collection, output_json):
|
|
603
|
+
"""List all tags in the library.
|
|
604
|
+
|
|
605
|
+
Returns all tags used in the library, or only tags from a specific collection.
|
|
606
|
+
|
|
607
|
+
Examples:
|
|
608
|
+
pyzotero tags
|
|
609
|
+
|
|
610
|
+
pyzotero tags --collection ABC123
|
|
611
|
+
|
|
612
|
+
pyzotero tags --json
|
|
613
|
+
|
|
614
|
+
"""
|
|
615
|
+
try:
|
|
616
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
617
|
+
zot = _get_zotero_client(locale)
|
|
618
|
+
|
|
619
|
+
# Fetch tags
|
|
620
|
+
if collection:
|
|
621
|
+
results = zot.collection_tags(collection)
|
|
622
|
+
else:
|
|
623
|
+
results = zot.tags()
|
|
624
|
+
|
|
625
|
+
if not results:
|
|
626
|
+
if output_json:
|
|
627
|
+
click.echo(json.dumps([]))
|
|
628
|
+
else:
|
|
629
|
+
click.echo("No tags found.")
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
if output_json:
|
|
633
|
+
click.echo(json.dumps(results, indent=2))
|
|
634
|
+
else:
|
|
635
|
+
click.echo(f"\nFound {len(results)} tags:\n")
|
|
636
|
+
for tag in sorted(results):
|
|
637
|
+
click.echo(f" {tag}")
|
|
638
|
+
|
|
639
|
+
except Exception as e:
|
|
640
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
641
|
+
sys.exit(1)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@main.command()
|
|
645
|
+
@click.argument("keys", nargs=-1, required=True)
|
|
646
|
+
@click.option(
|
|
647
|
+
"--json",
|
|
648
|
+
"output_json",
|
|
649
|
+
is_flag=True,
|
|
650
|
+
help="Output results as JSON",
|
|
651
|
+
)
|
|
652
|
+
@click.pass_context
|
|
653
|
+
def subset(ctx, keys, output_json):
|
|
654
|
+
"""Get multiple items by their keys in a single call.
|
|
655
|
+
|
|
656
|
+
Efficiently retrieve up to 50 items by key in a single API call.
|
|
657
|
+
Far more efficient than multiple individual item lookups.
|
|
658
|
+
|
|
659
|
+
Examples:
|
|
660
|
+
pyzotero subset ABC123 DEF456 GHI789
|
|
661
|
+
|
|
662
|
+
pyzotero subset ABC123 DEF456 --json
|
|
663
|
+
|
|
664
|
+
"""
|
|
665
|
+
try:
|
|
666
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
667
|
+
zot = _get_zotero_client(locale)
|
|
668
|
+
|
|
669
|
+
if len(keys) > 50: # noqa: PLR2004 - Zotero API limit
|
|
670
|
+
click.echo("Error: Maximum 50 items per call.", err=True)
|
|
671
|
+
sys.exit(1)
|
|
672
|
+
|
|
673
|
+
# Fetch items
|
|
674
|
+
results = zot.get_subset(list(keys))
|
|
675
|
+
|
|
676
|
+
if not results:
|
|
677
|
+
if output_json:
|
|
678
|
+
click.echo(json.dumps([]))
|
|
679
|
+
else:
|
|
680
|
+
click.echo("No items found.")
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
if output_json:
|
|
684
|
+
click.echo(json.dumps(results, indent=2))
|
|
685
|
+
else:
|
|
686
|
+
click.echo(f"\nFound {len(results)} items:\n")
|
|
687
|
+
for idx, item in enumerate(results, 1):
|
|
688
|
+
data = item.get("data", {})
|
|
689
|
+
title = data.get("title", "No title")
|
|
690
|
+
item_type = data.get("itemType", "Unknown")
|
|
691
|
+
item_key = data.get("key", "")
|
|
692
|
+
|
|
693
|
+
click.echo(f"{idx}. [{item_type}] {title}")
|
|
694
|
+
click.echo(f" Key: {item_key}")
|
|
695
|
+
click.echo()
|
|
696
|
+
|
|
697
|
+
except Exception as e:
|
|
698
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
699
|
+
sys.exit(1)
|
|
700
|
+
|
|
701
|
+
|
|
422
702
|
@main.command()
|
|
423
703
|
@click.argument("dois", nargs=-1)
|
|
424
704
|
@click.option(
|
|
@@ -513,5 +793,510 @@ def alldoi(ctx, dois, output_json): # noqa: PLR0912
|
|
|
513
793
|
sys.exit(1)
|
|
514
794
|
|
|
515
795
|
|
|
796
|
+
@main.command()
|
|
797
|
+
@click.pass_context
|
|
798
|
+
def doiindex(ctx):
|
|
799
|
+
"""Output the complete DOI-to-key mapping for the library.
|
|
800
|
+
|
|
801
|
+
Returns a JSON mapping of normalised DOIs to item keys and original DOIs.
|
|
802
|
+
This allows the skill to cache the index and avoid repeated full-library scans.
|
|
803
|
+
|
|
804
|
+
Output format:
|
|
805
|
+
{
|
|
806
|
+
"10.1234/abc": {"key": "ABC123", "original": "https://doi.org/10.1234/ABC"},
|
|
807
|
+
...
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
Examples:
|
|
811
|
+
pyzotero doiindex
|
|
812
|
+
|
|
813
|
+
pyzotero doiindex > doi_cache.json
|
|
814
|
+
|
|
815
|
+
"""
|
|
816
|
+
try:
|
|
817
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
818
|
+
zot = _get_zotero_client(locale)
|
|
819
|
+
|
|
820
|
+
click.echo("Building DOI index from library...", err=True)
|
|
821
|
+
doi_map = _build_doi_index_full(zot)
|
|
822
|
+
click.echo(f"Indexed {len(doi_map)} items with DOIs", err=True)
|
|
823
|
+
|
|
824
|
+
click.echo(json.dumps(doi_map, indent=2))
|
|
825
|
+
|
|
826
|
+
except Exception as e:
|
|
827
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
828
|
+
sys.exit(1)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@main.command()
|
|
832
|
+
@click.argument("key")
|
|
833
|
+
@click.pass_context
|
|
834
|
+
def fulltext(ctx, key):
|
|
835
|
+
"""Get full-text content of an attachment.
|
|
836
|
+
|
|
837
|
+
Returns the full-text content extracted from a PDF or other attachment.
|
|
838
|
+
The key should be the key of an attachment item (not a top-level item).
|
|
839
|
+
|
|
840
|
+
Output format:
|
|
841
|
+
{
|
|
842
|
+
"content": "Full-text extracted from PDF...",
|
|
843
|
+
"indexedPages": 50,
|
|
844
|
+
"totalPages": 50
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
Examples:
|
|
848
|
+
pyzotero fulltext ABC123
|
|
849
|
+
|
|
850
|
+
"""
|
|
851
|
+
try:
|
|
852
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
853
|
+
zot = _get_zotero_client(locale)
|
|
854
|
+
|
|
855
|
+
result = zot.fulltext_item(key)
|
|
856
|
+
|
|
857
|
+
if not result:
|
|
858
|
+
click.echo(json.dumps({"error": "No full-text content available"}))
|
|
859
|
+
return
|
|
860
|
+
|
|
861
|
+
click.echo(json.dumps(result, indent=2))
|
|
862
|
+
|
|
863
|
+
except Exception as e:
|
|
864
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
865
|
+
sys.exit(1)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _build_doi_index_full(zot):
|
|
869
|
+
"""Build a mapping of normalised DOIs to Zotero item keys and original DOIs.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
Dict mapping normalised DOIs to dicts with 'key' and 'original' fields
|
|
873
|
+
|
|
874
|
+
"""
|
|
875
|
+
doi_map = {}
|
|
876
|
+
all_items = zot.everything(zot.items())
|
|
877
|
+
|
|
878
|
+
for item in all_items:
|
|
879
|
+
data = item.get("data", {})
|
|
880
|
+
item_doi = data.get("DOI", "")
|
|
881
|
+
|
|
882
|
+
if item_doi:
|
|
883
|
+
normalised_doi = _normalize_doi(item_doi)
|
|
884
|
+
item_key = data.get("key", "")
|
|
885
|
+
|
|
886
|
+
if normalised_doi and item_key:
|
|
887
|
+
doi_map[normalised_doi] = {"key": item_key, "original": item_doi}
|
|
888
|
+
|
|
889
|
+
return doi_map
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _build_doi_index(zot):
|
|
893
|
+
"""Build a mapping of normalised DOIs to Zotero item keys.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
Dict mapping normalised DOIs to item keys
|
|
897
|
+
|
|
898
|
+
"""
|
|
899
|
+
doi_map = {}
|
|
900
|
+
all_items = zot.everything(zot.items())
|
|
901
|
+
|
|
902
|
+
for item in all_items:
|
|
903
|
+
data = item.get("data", {})
|
|
904
|
+
item_doi = data.get("DOI", "")
|
|
905
|
+
|
|
906
|
+
if item_doi:
|
|
907
|
+
normalised_doi = _normalize_doi(item_doi)
|
|
908
|
+
item_key = data.get("key", "")
|
|
909
|
+
|
|
910
|
+
if normalised_doi and item_key:
|
|
911
|
+
doi_map[normalised_doi] = item_key
|
|
912
|
+
|
|
913
|
+
return doi_map
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _format_s2_paper(paper, in_library=None):
|
|
917
|
+
"""Format a Semantic Scholar paper for output.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
paper: Normalised paper dict from semantic_scholar module
|
|
921
|
+
in_library: Boolean indicating if paper is in local Zotero
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
Formatted dict for output
|
|
925
|
+
|
|
926
|
+
"""
|
|
927
|
+
result = {
|
|
928
|
+
"paperId": paper.get("paperId"),
|
|
929
|
+
"doi": paper.get("doi"),
|
|
930
|
+
"title": paper.get("title"),
|
|
931
|
+
"authors": [a.get("name") for a in (paper.get("authors") or [])],
|
|
932
|
+
"year": paper.get("year"),
|
|
933
|
+
"venue": paper.get("venue"),
|
|
934
|
+
"citationCount": paper.get("citationCount"),
|
|
935
|
+
"referenceCount": paper.get("referenceCount"),
|
|
936
|
+
"isOpenAccess": paper.get("isOpenAccess"),
|
|
937
|
+
"openAccessPdfUrl": paper.get("openAccessPdfUrl"),
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if in_library is not None:
|
|
941
|
+
result["inLibrary"] = in_library
|
|
942
|
+
|
|
943
|
+
return result
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _annotate_with_library(papers, doi_map):
|
|
947
|
+
"""Annotate papers with in_library status based on DOI matching.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
papers: List of normalised paper dicts
|
|
951
|
+
doi_map: Dict mapping normalised DOIs to Zotero item keys
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
List of formatted paper dicts with inLibrary field
|
|
955
|
+
|
|
956
|
+
"""
|
|
957
|
+
results = []
|
|
958
|
+
for paper in papers:
|
|
959
|
+
doi = paper.get("doi")
|
|
960
|
+
in_library = False
|
|
961
|
+
if doi:
|
|
962
|
+
normalised = _normalize_doi(doi)
|
|
963
|
+
in_library = normalised in doi_map
|
|
964
|
+
results.append(_format_s2_paper(paper, in_library))
|
|
965
|
+
return results
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
@main.command()
|
|
969
|
+
@click.option(
|
|
970
|
+
"--doi",
|
|
971
|
+
required=True,
|
|
972
|
+
help="DOI of the paper to find related papers for",
|
|
973
|
+
)
|
|
974
|
+
@click.option(
|
|
975
|
+
"--limit",
|
|
976
|
+
type=int,
|
|
977
|
+
default=20,
|
|
978
|
+
help="Maximum number of results to return (default: 20, max: 500)",
|
|
979
|
+
)
|
|
980
|
+
@click.option(
|
|
981
|
+
"--min-citations",
|
|
982
|
+
type=int,
|
|
983
|
+
default=0,
|
|
984
|
+
help="Minimum citation count filter (default: 0)",
|
|
985
|
+
)
|
|
986
|
+
@click.option(
|
|
987
|
+
"--check-library/--no-check-library",
|
|
988
|
+
default=True,
|
|
989
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
990
|
+
)
|
|
991
|
+
@click.pass_context
|
|
992
|
+
def related(ctx, doi, limit, min_citations, check_library):
|
|
993
|
+
"""Find papers related to a given paper using Semantic Scholar.
|
|
994
|
+
|
|
995
|
+
Uses SPECTER2 embeddings to find semantically similar papers.
|
|
996
|
+
|
|
997
|
+
Examples:
|
|
998
|
+
pyzotero related --doi "10.1038/nature12373"
|
|
999
|
+
|
|
1000
|
+
pyzotero related --doi "10.1038/nature12373" --limit 50
|
|
1001
|
+
|
|
1002
|
+
pyzotero related --doi "10.1038/nature12373" --min-citations 100
|
|
1003
|
+
|
|
1004
|
+
"""
|
|
1005
|
+
try:
|
|
1006
|
+
# Get recommendations from Semantic Scholar
|
|
1007
|
+
click.echo(f"Fetching related papers for DOI: {doi}...", err=True)
|
|
1008
|
+
result = get_recommendations(doi, id_type="doi", limit=limit)
|
|
1009
|
+
papers = result.get("papers", [])
|
|
1010
|
+
|
|
1011
|
+
# Apply citation filter
|
|
1012
|
+
if min_citations > 0:
|
|
1013
|
+
papers = filter_by_citations(papers, min_citations)
|
|
1014
|
+
|
|
1015
|
+
if not papers:
|
|
1016
|
+
click.echo(json.dumps({"count": 0, "papers": []}))
|
|
1017
|
+
return
|
|
1018
|
+
|
|
1019
|
+
# Optionally annotate with library status
|
|
1020
|
+
if check_library:
|
|
1021
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
1022
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
1023
|
+
zot = _get_zotero_client(locale)
|
|
1024
|
+
doi_map = _build_doi_index(zot)
|
|
1025
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
1026
|
+
else:
|
|
1027
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
1028
|
+
|
|
1029
|
+
click.echo(
|
|
1030
|
+
json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
except PaperNotFoundError:
|
|
1034
|
+
click.echo("Error: Paper not found in Semantic Scholar.", err=True)
|
|
1035
|
+
sys.exit(1)
|
|
1036
|
+
except RateLimitError:
|
|
1037
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
1038
|
+
sys.exit(1)
|
|
1039
|
+
except SemanticScholarError as e:
|
|
1040
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1041
|
+
sys.exit(1)
|
|
1042
|
+
except Exception as e:
|
|
1043
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1044
|
+
sys.exit(1)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
@main.command()
|
|
1048
|
+
@click.option(
|
|
1049
|
+
"--doi",
|
|
1050
|
+
required=True,
|
|
1051
|
+
help="DOI of the paper to find citations for",
|
|
1052
|
+
)
|
|
1053
|
+
@click.option(
|
|
1054
|
+
"--limit",
|
|
1055
|
+
type=int,
|
|
1056
|
+
default=100,
|
|
1057
|
+
help="Maximum number of results to return (default: 100, max: 1000)",
|
|
1058
|
+
)
|
|
1059
|
+
@click.option(
|
|
1060
|
+
"--min-citations",
|
|
1061
|
+
type=int,
|
|
1062
|
+
default=0,
|
|
1063
|
+
help="Minimum citation count filter (default: 0)",
|
|
1064
|
+
)
|
|
1065
|
+
@click.option(
|
|
1066
|
+
"--check-library/--no-check-library",
|
|
1067
|
+
default=True,
|
|
1068
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
1069
|
+
)
|
|
1070
|
+
@click.pass_context
|
|
1071
|
+
def citations(ctx, doi, limit, min_citations, check_library):
|
|
1072
|
+
"""Find papers that cite a given paper using Semantic Scholar.
|
|
1073
|
+
|
|
1074
|
+
Examples:
|
|
1075
|
+
pyzotero citations --doi "10.1038/nature12373"
|
|
1076
|
+
|
|
1077
|
+
pyzotero citations --doi "10.1038/nature12373" --limit 50
|
|
1078
|
+
|
|
1079
|
+
pyzotero citations --doi "10.1038/nature12373" --min-citations 50
|
|
1080
|
+
|
|
1081
|
+
"""
|
|
1082
|
+
try:
|
|
1083
|
+
# Get citations from Semantic Scholar
|
|
1084
|
+
click.echo(f"Fetching citations for DOI: {doi}...", err=True)
|
|
1085
|
+
result = get_citations(doi, id_type="doi", limit=limit)
|
|
1086
|
+
papers = result.get("papers", [])
|
|
1087
|
+
|
|
1088
|
+
# Apply citation filter
|
|
1089
|
+
if min_citations > 0:
|
|
1090
|
+
papers = filter_by_citations(papers, min_citations)
|
|
1091
|
+
|
|
1092
|
+
if not papers:
|
|
1093
|
+
click.echo(json.dumps({"count": 0, "papers": []}))
|
|
1094
|
+
return
|
|
1095
|
+
|
|
1096
|
+
# Optionally annotate with library status
|
|
1097
|
+
if check_library:
|
|
1098
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
1099
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
1100
|
+
zot = _get_zotero_client(locale)
|
|
1101
|
+
doi_map = _build_doi_index(zot)
|
|
1102
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
1103
|
+
else:
|
|
1104
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
1105
|
+
|
|
1106
|
+
click.echo(
|
|
1107
|
+
json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
except PaperNotFoundError:
|
|
1111
|
+
click.echo("Error: Paper not found in Semantic Scholar.", err=True)
|
|
1112
|
+
sys.exit(1)
|
|
1113
|
+
except RateLimitError:
|
|
1114
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
1115
|
+
sys.exit(1)
|
|
1116
|
+
except SemanticScholarError as e:
|
|
1117
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1118
|
+
sys.exit(1)
|
|
1119
|
+
except Exception as e:
|
|
1120
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1121
|
+
sys.exit(1)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
@main.command()
|
|
1125
|
+
@click.option(
|
|
1126
|
+
"--doi",
|
|
1127
|
+
required=True,
|
|
1128
|
+
help="DOI of the paper to find references for",
|
|
1129
|
+
)
|
|
1130
|
+
@click.option(
|
|
1131
|
+
"--limit",
|
|
1132
|
+
type=int,
|
|
1133
|
+
default=100,
|
|
1134
|
+
help="Maximum number of results to return (default: 100, max: 1000)",
|
|
1135
|
+
)
|
|
1136
|
+
@click.option(
|
|
1137
|
+
"--min-citations",
|
|
1138
|
+
type=int,
|
|
1139
|
+
default=0,
|
|
1140
|
+
help="Minimum citation count filter (default: 0)",
|
|
1141
|
+
)
|
|
1142
|
+
@click.option(
|
|
1143
|
+
"--check-library/--no-check-library",
|
|
1144
|
+
default=True,
|
|
1145
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
1146
|
+
)
|
|
1147
|
+
@click.pass_context
|
|
1148
|
+
def references(ctx, doi, limit, min_citations, check_library):
|
|
1149
|
+
"""Find papers referenced by a given paper using Semantic Scholar.
|
|
1150
|
+
|
|
1151
|
+
Examples:
|
|
1152
|
+
pyzotero references --doi "10.1038/nature12373"
|
|
1153
|
+
|
|
1154
|
+
pyzotero references --doi "10.1038/nature12373" --limit 50
|
|
1155
|
+
|
|
1156
|
+
pyzotero references --doi "10.1038/nature12373" --min-citations 100
|
|
1157
|
+
|
|
1158
|
+
"""
|
|
1159
|
+
try:
|
|
1160
|
+
# Get references from Semantic Scholar
|
|
1161
|
+
click.echo(f"Fetching references for DOI: {doi}...", err=True)
|
|
1162
|
+
result = get_references(doi, id_type="doi", limit=limit)
|
|
1163
|
+
papers = result.get("papers", [])
|
|
1164
|
+
|
|
1165
|
+
# Apply citation filter
|
|
1166
|
+
if min_citations > 0:
|
|
1167
|
+
papers = filter_by_citations(papers, min_citations)
|
|
1168
|
+
|
|
1169
|
+
if not papers:
|
|
1170
|
+
click.echo(json.dumps({"count": 0, "papers": []}))
|
|
1171
|
+
return
|
|
1172
|
+
|
|
1173
|
+
# Optionally annotate with library status
|
|
1174
|
+
if check_library:
|
|
1175
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
1176
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
1177
|
+
zot = _get_zotero_client(locale)
|
|
1178
|
+
doi_map = _build_doi_index(zot)
|
|
1179
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
1180
|
+
else:
|
|
1181
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
1182
|
+
|
|
1183
|
+
click.echo(
|
|
1184
|
+
json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
except PaperNotFoundError:
|
|
1188
|
+
click.echo("Error: Paper not found in Semantic Scholar.", err=True)
|
|
1189
|
+
sys.exit(1)
|
|
1190
|
+
except RateLimitError:
|
|
1191
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
1192
|
+
sys.exit(1)
|
|
1193
|
+
except SemanticScholarError as e:
|
|
1194
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1195
|
+
sys.exit(1)
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1198
|
+
sys.exit(1)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
@main.command()
|
|
1202
|
+
@click.option(
|
|
1203
|
+
"-q",
|
|
1204
|
+
"--query",
|
|
1205
|
+
required=True,
|
|
1206
|
+
help="Search query string",
|
|
1207
|
+
)
|
|
1208
|
+
@click.option(
|
|
1209
|
+
"--limit",
|
|
1210
|
+
type=int,
|
|
1211
|
+
default=20,
|
|
1212
|
+
help="Maximum number of results to return (default: 20, max: 100)",
|
|
1213
|
+
)
|
|
1214
|
+
@click.option(
|
|
1215
|
+
"--year",
|
|
1216
|
+
help="Year filter (e.g., '2020', '2018-2022', '2020-')",
|
|
1217
|
+
)
|
|
1218
|
+
@click.option(
|
|
1219
|
+
"--open-access/--no-open-access",
|
|
1220
|
+
default=False,
|
|
1221
|
+
help="Only return open access papers (default: False)",
|
|
1222
|
+
)
|
|
1223
|
+
@click.option(
|
|
1224
|
+
"--sort",
|
|
1225
|
+
type=click.Choice(["citations", "year"], case_sensitive=False),
|
|
1226
|
+
help="Sort results by citation count or year (descending)",
|
|
1227
|
+
)
|
|
1228
|
+
@click.option(
|
|
1229
|
+
"--min-citations",
|
|
1230
|
+
type=int,
|
|
1231
|
+
default=0,
|
|
1232
|
+
help="Minimum citation count filter (default: 0)",
|
|
1233
|
+
)
|
|
1234
|
+
@click.option(
|
|
1235
|
+
"--check-library/--no-check-library",
|
|
1236
|
+
default=True,
|
|
1237
|
+
help="Check if papers exist in local Zotero (default: True)",
|
|
1238
|
+
)
|
|
1239
|
+
@click.pass_context
|
|
1240
|
+
def s2search(ctx, query, limit, year, open_access, sort, min_citations, check_library):
|
|
1241
|
+
"""Search for papers on Semantic Scholar.
|
|
1242
|
+
|
|
1243
|
+
Search across Semantic Scholar's index of over 200M papers.
|
|
1244
|
+
|
|
1245
|
+
Examples:
|
|
1246
|
+
pyzotero s2search -q "climate adaptation"
|
|
1247
|
+
|
|
1248
|
+
pyzotero s2search -q "machine learning" --year 2020-2024
|
|
1249
|
+
|
|
1250
|
+
pyzotero s2search -q "neural networks" --open-access --limit 50
|
|
1251
|
+
|
|
1252
|
+
pyzotero s2search -q "deep learning" --sort citations --min-citations 100
|
|
1253
|
+
|
|
1254
|
+
"""
|
|
1255
|
+
try:
|
|
1256
|
+
# Search Semantic Scholar
|
|
1257
|
+
click.echo(f'Searching Semantic Scholar for: "{query}"...', err=True)
|
|
1258
|
+
result = search_papers(
|
|
1259
|
+
query,
|
|
1260
|
+
limit=limit,
|
|
1261
|
+
year=year,
|
|
1262
|
+
open_access_only=open_access,
|
|
1263
|
+
sort=sort,
|
|
1264
|
+
min_citations=min_citations,
|
|
1265
|
+
)
|
|
1266
|
+
papers = result.get("papers", [])
|
|
1267
|
+
total = result.get("total", len(papers))
|
|
1268
|
+
|
|
1269
|
+
if not papers:
|
|
1270
|
+
click.echo(json.dumps({"count": 0, "total": total, "papers": []}))
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
# Optionally annotate with library status
|
|
1274
|
+
if check_library:
|
|
1275
|
+
click.echo("Checking local Zotero library...", err=True)
|
|
1276
|
+
locale = ctx.obj.get("locale", "en-US")
|
|
1277
|
+
zot = _get_zotero_client(locale)
|
|
1278
|
+
doi_map = _build_doi_index(zot)
|
|
1279
|
+
output_papers = _annotate_with_library(papers, doi_map)
|
|
1280
|
+
else:
|
|
1281
|
+
output_papers = [_format_s2_paper(p) for p in papers]
|
|
1282
|
+
|
|
1283
|
+
click.echo(
|
|
1284
|
+
json.dumps(
|
|
1285
|
+
{"count": len(output_papers), "total": total, "papers": output_papers},
|
|
1286
|
+
indent=2,
|
|
1287
|
+
)
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
except RateLimitError:
|
|
1291
|
+
click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
|
|
1292
|
+
sys.exit(1)
|
|
1293
|
+
except SemanticScholarError as e:
|
|
1294
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1295
|
+
sys.exit(1)
|
|
1296
|
+
except Exception as e:
|
|
1297
|
+
click.echo(f"Error: {e!s}", err=True)
|
|
1298
|
+
sys.exit(1)
|
|
1299
|
+
|
|
1300
|
+
|
|
516
1301
|
if __name__ == "__main__":
|
|
517
1302
|
main()
|