scholarlib 0.3.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.
Files changed (52) hide show
  1. scholarlib/__init__.py +3 -0
  2. scholarlib/adapters.py +94 -0
  3. scholarlib/apis/__init__.py +0 -0
  4. scholarlib/apis/core_api.py +41 -0
  5. scholarlib/apis/crossref.py +93 -0
  6. scholarlib/apis/openaire.py +88 -0
  7. scholarlib/apis/openalex.py +199 -0
  8. scholarlib/apis/orcid.py +118 -0
  9. scholarlib/apis/semantic_scholar.py +99 -0
  10. scholarlib/apis/unpaywall.py +47 -0
  11. scholarlib/apis/zotero_local.py +156 -0
  12. scholarlib/cli/__init__.py +0 -0
  13. scholarlib/cli/jstor_index.py +165 -0
  14. scholarlib/cli/litreview.py +326 -0
  15. scholarlib/cli/scholarfocus.py +662 -0
  16. scholarlib/config.example.yaml +68 -0
  17. scholarlib/config.py +172 -0
  18. scholarlib/dedup.py +177 -0
  19. scholarlib/http/__init__.py +0 -0
  20. scholarlib/http/base.py +212 -0
  21. scholarlib/http/budget.py +162 -0
  22. scholarlib/http/cache.py +144 -0
  23. scholarlib/http/policy.py +59 -0
  24. scholarlib/jstor/__init__.py +0 -0
  25. scholarlib/jstor/build.py +336 -0
  26. scholarlib/jstor/indexes.sql +8 -0
  27. scholarlib/jstor/query.py +295 -0
  28. scholarlib/jstor/schema.sql +48 -0
  29. scholarlib/pipeline/__init__.py +0 -0
  30. scholarlib/pipeline/abstracts.py +59 -0
  31. scholarlib/pipeline/cluster.py +117 -0
  32. scholarlib/pipeline/context.py +92 -0
  33. scholarlib/pipeline/disambiguate.py +377 -0
  34. scholarlib/pipeline/jstor_coverage.py +137 -0
  35. scholarlib/pipeline/oa_links.py +61 -0
  36. scholarlib/pipeline/profile.py +333 -0
  37. scholarlib/pipeline/rank.py +139 -0
  38. scholarlib/pipeline/seed.py +97 -0
  39. scholarlib/pipeline/snowball.py +120 -0
  40. scholarlib/progress.py +131 -0
  41. scholarlib/records.py +202 -0
  42. scholarlib/render/__init__.py +0 -0
  43. scholarlib/render/bib.py +139 -0
  44. scholarlib/render/json_out.py +120 -0
  45. scholarlib/render/narrative.py +167 -0
  46. scholarlib/render/systematic.py +93 -0
  47. scholarlib-0.3.0.dist-info/METADATA +232 -0
  48. scholarlib-0.3.0.dist-info/RECORD +52 -0
  49. scholarlib-0.3.0.dist-info/WHEEL +5 -0
  50. scholarlib-0.3.0.dist-info/entry_points.txt +4 -0
  51. scholarlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  52. scholarlib-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,99 @@
1
+ """Semantic Scholar client.
2
+
3
+ Disabled by default. OpenAlex covers ~92% of abstracts, so S2AG is an
4
+ enhancement rather than a dependency; its remaining edge is the `tldr`
5
+ summaries and its own fields-of-study labels.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Optional
12
+
13
+ from scholarlib.http.base import BaseClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ BASE_URL = "https://api.semanticscholar.org/graph/v1"
18
+
19
+ AUTHOR_FIELDS = "authorId,name,externalIds,affiliations,paperCount,citationCount,hIndex"
20
+ PAPER_FIELDS = (
21
+ "paperId,externalIds,title,abstract,year,venue,"
22
+ "fieldsOfStudy,s2FieldsOfStudy,citationCount,tldr"
23
+ )
24
+
25
+
26
+ class SemanticScholarClient(BaseClient):
27
+ name = "semantic_scholar"
28
+ base_url = BASE_URL
29
+
30
+ def __init__(self, api_key: Optional[str] = None, enabled: bool = False, **kw):
31
+ super().__init__(**kw)
32
+ self.api_key = api_key
33
+ self.enabled = enabled
34
+ # Unauthenticated S2AG 429s almost immediately.
35
+ if not api_key:
36
+ object.__setattr__(self.policy, "min_interval", 1.1)
37
+
38
+ def _available(self) -> bool:
39
+ return self.enabled
40
+
41
+ def _unavailable_reason(self) -> str:
42
+ return "semantic_scholar: disabled (enable with --enable-s2ag)"
43
+
44
+ def _auth_headers(self) -> dict:
45
+ return {"x-api-key": self.api_key} if self.api_key else {}
46
+
47
+ def health(self) -> tuple[bool, str]:
48
+ """Probe whether the key actually works. Returns (ok, message)."""
49
+ was = self.enabled
50
+ self.enabled = True
51
+ try:
52
+ data = self.get("/paper/search", {"query": "anthropology", "limit": 1,
53
+ "fields": "title"}, use_cache=False)
54
+ finally:
55
+ self.enabled = was
56
+ if data and data.get("data") is not None:
57
+ return True, "ok"
58
+ if not self.api_key:
59
+ return False, "no API key configured"
60
+ return False, "key rejected or rate-limited (403/429)"
61
+
62
+ def get_paper(self, paper_id: str, fields: str = PAPER_FIELDS) -> Optional[dict]:
63
+ """`paper_id` may be an S2 id or a `DOI:10.xxxx/yyy` string."""
64
+ return self.get(f"/paper/{paper_id}", {"fields": fields})
65
+
66
+ def get_paper_by_doi(self, doi: str, fields: str = PAPER_FIELDS) -> Optional[dict]:
67
+ d = str(doi).replace("https://doi.org/", "").strip()
68
+ return self.get_paper(f"DOI:{d}", fields)
69
+
70
+ def get_abstract(self, doi: str) -> Optional[str]:
71
+ p = self.get_paper_by_doi(doi, fields="abstract")
72
+ return (p or {}).get("abstract")
73
+
74
+ def get_tldr(self, doi: str) -> Optional[str]:
75
+ p = self.get_paper_by_doi(doi, fields="tldr")
76
+ tldr = (p or {}).get("tldr") or {}
77
+ return tldr.get("text")
78
+
79
+ def search_authors(self, name: str, limit: int = 10) -> list[dict]:
80
+ data = self.get("/author/search", {"query": name, "limit": limit,
81
+ "fields": AUTHOR_FIELDS})
82
+ return (data or {}).get("data", [])
83
+
84
+ def get_author_papers(self, author_id: str, max_papers: int = 100) -> list[dict]:
85
+ out: list[dict] = []
86
+ offset = 0
87
+ while len(out) < max_papers:
88
+ data = self.get(f"/author/{author_id}/papers", {
89
+ "fields": PAPER_FIELDS, "limit": min(100, max_papers - len(out)),
90
+ "offset": offset,
91
+ })
92
+ if not data:
93
+ break
94
+ rows = data.get("data") or []
95
+ out.extend(rows)
96
+ if not rows or "next" not in data:
97
+ break
98
+ offset = data["next"]
99
+ return out[:max_papers]
@@ -0,0 +1,47 @@
1
+ """Unpaywall client: open-access status and full-text locations per DOI.
2
+
3
+ Free, no key, but the email parameter is required by the API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from scholarlib.http.base import BaseClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ BASE_URL = "https://api.unpaywall.org/v2"
16
+
17
+
18
+ class UnpaywallClient(BaseClient):
19
+ name = "unpaywall"
20
+ base_url = BASE_URL
21
+ default_ttl = 30 * 86400 # OA status genuinely changes over time
22
+
23
+ def __init__(self, email: Optional[str] = None, **kw):
24
+ super().__init__(contact_email=email, **kw)
25
+ self.email = email
26
+
27
+ def _available(self) -> bool:
28
+ return bool(self.email)
29
+
30
+ def _unavailable_reason(self) -> str:
31
+ return "unpaywall: no email configured (apis.unpaywall.email)"
32
+
33
+ def _auth_params(self) -> dict:
34
+ return {"email": self.email} if self.email else {}
35
+
36
+ def get_oa(self, doi: str) -> Optional[dict]:
37
+ d = str(doi).replace("https://doi.org/", "").strip()
38
+ return self.get(f"/{d}")
39
+
40
+ def oa_for(self, doi: str) -> tuple[Optional[str], Optional[str]]:
41
+ """Return (oa_status, best full-text url)."""
42
+ data = self.get_oa(doi)
43
+ if not data:
44
+ return None, None
45
+ best = data.get("best_oa_location") or {}
46
+ url = best.get("url_for_pdf") or best.get("url")
47
+ return data.get("oa_status"), url
@@ -0,0 +1,156 @@
1
+ """Zotero local HTTP API bridge.
2
+
3
+ Read-only, and only answers while the Zotero desktop app is running with
4
+ Settings -> Advanced -> "Allow other applications on this computer to
5
+ communicate with Zotero" enabled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Iterator, Optional
12
+
13
+ from scholarlib import dedup
14
+ from scholarlib.http.base import BaseClient
15
+ from scholarlib.records import Record
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ DEFAULT_BASE = "http://localhost:23119/api/users/0"
20
+
21
+ _TYPE_MAP = {
22
+ "journalArticle": "article",
23
+ "book": "book",
24
+ "bookSection": "chapter",
25
+ "conferencePaper": "conference-paper",
26
+ "thesis": "dissertation",
27
+ "preprint": "preprint",
28
+ "report": "report",
29
+ }
30
+
31
+ _SKIP_TYPES = {"attachment", "note", "annotation"}
32
+
33
+
34
+ class ZoteroLocalClient(BaseClient):
35
+ name = "zotero"
36
+
37
+ def __init__(self, base_url: str = DEFAULT_BASE, enabled: bool = True, **kw):
38
+ super().__init__(**kw)
39
+ self.base_url = base_url
40
+ self.enabled = enabled
41
+ self._checked: Optional[bool] = None
42
+
43
+ def _available(self) -> bool:
44
+ return self.enabled
45
+
46
+ def available(self) -> bool:
47
+ """Probe once; cache the answer for the run."""
48
+ if self._checked is None:
49
+ if not self.enabled:
50
+ self._checked = False
51
+ else:
52
+ self._checked = self.get("/items", {"limit": 1}, use_cache=False) is not None
53
+ if not self._checked:
54
+ logger.warning(
55
+ "Zotero is not reachable at %s. Start Zotero and enable "
56
+ 'Settings -> Advanced -> "Allow other applications on this '
57
+ 'computer to communicate with Zotero". Continuing without it.',
58
+ self.base_url,
59
+ )
60
+ return self._checked
61
+
62
+ def collections(self) -> list[dict]:
63
+ return self.get("/collections", {"limit": 100}, use_cache=False) or []
64
+
65
+ def collection_by_name(self, name: str) -> Optional[dict]:
66
+ target = (name or "").strip().casefold()
67
+ for c in self.collections():
68
+ if str((c.get("data") or {}).get("name", "")).strip().casefold() == target:
69
+ return c
70
+ return None
71
+
72
+ def items(self, *, collection_key: Optional[str] = None,
73
+ limit: int = 100, max_items: int = 10000) -> Iterator[dict]:
74
+ path = f"/collections/{collection_key}/items" if collection_key else "/items"
75
+ start = 0
76
+ while start < max_items:
77
+ rows = self.get(path, {"limit": limit, "start": start}, use_cache=False)
78
+ if not rows:
79
+ return
80
+ for r in rows:
81
+ yield r
82
+ if len(rows) < limit:
83
+ return
84
+ start += len(rows)
85
+
86
+ @staticmethod
87
+ def to_record(item: dict) -> Optional[Record]:
88
+ d = item.get("data") or {}
89
+ itype = d.get("itemType")
90
+ if not itype or itype in _SKIP_TYPES:
91
+ return None
92
+ creators = [
93
+ " ".join(x for x in (c.get("firstName"), c.get("lastName")) if x) or c.get("name", "")
94
+ for c in (d.get("creators") or [])
95
+ if c.get("creatorType") == "author"
96
+ ]
97
+ year = None
98
+ date = str(d.get("date") or "")
99
+ for chunk in date.replace("-", " ").replace("/", " ").split():
100
+ if len(chunk) == 4 and chunk.isdigit():
101
+ year = int(chunk)
102
+ break
103
+ return Record(
104
+ doi=d.get("DOI"),
105
+ title=d.get("title"),
106
+ authors=[c for c in creators if c],
107
+ year=year,
108
+ venue=d.get("publicationTitle") or d.get("bookTitle") or d.get("publisher"),
109
+ type=_TYPE_MAP.get(itype, itype),
110
+ language=d.get("language") or None,
111
+ abstract=d.get("abstractNote") or None,
112
+ abstract_source="zotero" if d.get("abstractNote") else None,
113
+ in_zotero=True,
114
+ zotero_key=d.get("key"),
115
+ zotero_citekey=(d.get("citationKey") or None),
116
+ provenance=["zotero"],
117
+ )
118
+
119
+ def load_library(self, collection_key: Optional[str] = None) -> list[Record]:
120
+ if not self.available():
121
+ return []
122
+ out: list[Record] = []
123
+ for item in self.items(collection_key=collection_key):
124
+ rec = self.to_record(item)
125
+ if rec and (rec.doi or rec.title):
126
+ out.append(rec)
127
+ logger.info("Zotero: loaded %d items", len(out))
128
+ return out
129
+
130
+
131
+ class ZoteroLookup:
132
+ """DOI-first, then blocked title match, for 'do I already own this?'."""
133
+
134
+ def __init__(self, records: list[Record]):
135
+ self.by_doi: dict[str, Record] = {}
136
+ self.by_block: dict[tuple, list[Record]] = {}
137
+ for r in records:
138
+ if r.doi:
139
+ self.by_doi[r.doi] = r
140
+ self.by_block.setdefault((r.first_surname, r.year), []).append(r)
141
+
142
+ def __len__(self) -> int:
143
+ return len(self.by_doi) + sum(len(v) for v in self.by_block.values())
144
+
145
+ def match(self, rec: Record, *, threshold: float = 0.93,
146
+ year_slack: int = 1) -> Optional[Record]:
147
+ if rec.doi and rec.doi in self.by_doi:
148
+ return self.by_doi[rec.doi]
149
+ if not dedup.titles_comparable(rec.title):
150
+ return None
151
+ for dy in range(-year_slack, year_slack + 1):
152
+ y = (rec.year + dy) if rec.year else None
153
+ for cand in self.by_block.get((rec.first_surname, y), []):
154
+ if dedup.best_title_similarity(rec.title, cand.title) >= threshold:
155
+ return cand
156
+ return None
File without changes
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env python3
2
+ """Build and query the local JSTOR metadata index."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import sqlite3
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from scholarlib.config import ConfigError, expand_path, load_config
14
+
15
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(levelname)s %(message)s")
16
+ logger = logging.getLogger("jstor-index")
17
+
18
+
19
+ def _db_path(args, cfg) -> Path:
20
+ p = args.db or (cfg.get("jstor") or {}).get("db_path")
21
+ if not p:
22
+ raise ConfigError("No JSTOR db path: pass --db or set jstor.db_path in config.yaml")
23
+ return Path(str(p)).expanduser()
24
+
25
+
26
+ def cmd_build(args, cfg) -> int:
27
+ from scholarlib.jstor.build import build
28
+
29
+ src = args.source or (cfg.get("jstor") or {}).get("source_path")
30
+ if not src:
31
+ raise ConfigError("No JSTOR source: pass --source or set jstor.source_path")
32
+ disciplines = None
33
+ if args.disciplines and not args.all_disciplines:
34
+ disciplines = frozenset(d.strip() for d in args.disciplines.split(",") if d.strip())
35
+ elif not args.all_disciplines:
36
+ cfg_disc = (cfg.get("jstor") or {}).get("disciplines")
37
+ if cfg_disc:
38
+ disciplines = frozenset(cfg_disc)
39
+ languages = frozenset(x.strip() for x in args.languages.split(",")) if args.languages else None
40
+
41
+ stats = build(
42
+ Path(str(src)).expanduser(),
43
+ _db_path(args, cfg),
44
+ disciplines=disciplines,
45
+ languages=languages,
46
+ keep_reviews=args.keep_reviews or (cfg.get("jstor") or {}).get("keep_reviews", False),
47
+ resume=args.resume,
48
+ force=args.force,
49
+ progress_every=args.progress_every,
50
+ limit=args.limit,
51
+ )
52
+ print(json.dumps(stats, indent=2))
53
+ return 0
54
+
55
+
56
+ def cmd_stats(args, cfg) -> int:
57
+ from scholarlib.jstor.query import JstorIndex
58
+
59
+ idx = JstorIndex(_db_path(args, cfg))
60
+ print(json.dumps(idx.stats(), indent=2))
61
+ return 0
62
+
63
+
64
+ def cmd_search(args, cfg) -> int:
65
+ from scholarlib.jstor.query import JstorIndex
66
+
67
+ idx = JstorIndex(_db_path(args, cfg))
68
+ rows = idx.search(
69
+ args.text,
70
+ disciplines=args.disciplines.split(",") if args.disciplines else None,
71
+ types=args.types.split(",") if args.types else None,
72
+ from_year=args.from_year,
73
+ to_year=args.to_year,
74
+ limit=args.limit or 20,
75
+ )
76
+ if args.json:
77
+ print(json.dumps([r.to_dict() for r in rows], indent=2, ensure_ascii=False))
78
+ else:
79
+ for r in rows:
80
+ who = r.first_surname or "?"
81
+ print(f"{r.year or '????'} {who:<18.18} [{r.type or '?':<9.9}] {r.title}")
82
+ if r.jstor:
83
+ print(f" {r.jstor.get('url')}")
84
+ return 0
85
+
86
+
87
+ def cmd_match(args, cfg) -> int:
88
+ from scholarlib.jstor.query import JstorIndex
89
+ from scholarlib.records import Record
90
+
91
+ idx = JstorIndex(_db_path(args, cfg))
92
+ probe = Record(title=args.title, authors=[args.author] if args.author else [], year=args.year)
93
+ hit, score, method = idx.find_match(probe)
94
+ print(json.dumps({
95
+ "matched": hit is not None,
96
+ "score": round(score, 3),
97
+ "method": method,
98
+ "record": hit.to_dict() if hit else None,
99
+ }, indent=2, ensure_ascii=False))
100
+ return 0
101
+
102
+
103
+ def main() -> None:
104
+ ap = argparse.ArgumentParser(description="Build and query the local JSTOR metadata index.")
105
+ ap.add_argument("--config", help="Path to config.yaml")
106
+ ap.add_argument("--db", help="Path to the index database")
107
+ sub = ap.add_subparsers(dest="cmd", required=True)
108
+
109
+ b = sub.add_parser("build", help="Build or refresh the index")
110
+ b.add_argument("--source", help="Path to the .jsonl.gz dump")
111
+ b.add_argument("--disciplines", help="Comma-separated discipline allowlist")
112
+ b.add_argument("--all-disciplines", action="store_true", help="Index every discipline")
113
+ b.add_argument("--languages", help="Comma-separated ISO-639-2/B codes")
114
+ b.add_argument("--keep-reviews", action="store_true",
115
+ help="Also index book reviews (~90%% are untitled)")
116
+ b.add_argument("--resume", action="store_true", help="Continue an interrupted build")
117
+ b.add_argument("--force", action="store_true", help="Rebuild even if up to date")
118
+ b.add_argument("--progress-every", type=int, default=250_000)
119
+ b.add_argument("--limit", type=int, help="Stop after N kept records (for testing)")
120
+ b.set_defaults(func=cmd_build)
121
+
122
+ s = sub.add_parser("stats", help="Show what is in the index")
123
+ s.set_defaults(func=cmd_stats)
124
+
125
+ q = sub.add_parser("search", help="Full-text search the index")
126
+ q.add_argument("--text", required=True)
127
+ q.add_argument("--disciplines")
128
+ q.add_argument("--types")
129
+ q.add_argument("--from-year", type=int)
130
+ q.add_argument("--to-year", type=int)
131
+ q.add_argument("--limit", type=int)
132
+ q.add_argument("--json", action="store_true")
133
+ q.set_defaults(func=cmd_search)
134
+
135
+ m = sub.add_parser("match", help="Find the JSTOR record matching a title/author/year")
136
+ m.add_argument("--title", required=True)
137
+ m.add_argument("--author")
138
+ m.add_argument("--year", type=int)
139
+ m.set_defaults(func=cmd_match)
140
+
141
+ args = ap.parse_args()
142
+ try:
143
+ cfg = load_config(args.config)
144
+ sys.exit(args.func(args, cfg))
145
+ except ConfigError as e:
146
+ logger.error("Configuration problem: %s", e)
147
+ sys.exit(3)
148
+ except FileNotFoundError as e:
149
+ logger.error("%s", e)
150
+ sys.exit(5)
151
+ except sqlite3.OperationalError as e:
152
+ logger.error("Index not usable (%s). Build it first: "
153
+ "python -m scholarlib.cli.jstor_index build", e)
154
+ sys.exit(5)
155
+ except KeyboardInterrupt:
156
+ logger.error("Interrupted — rerun with --resume to continue")
157
+ sys.exit(1)
158
+ except Exception as e:
159
+ logger.error("Failed: %s", e)
160
+ logger.debug("traceback", exc_info=True)
161
+ sys.exit(1)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()