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.
- scholarlib/__init__.py +3 -0
- scholarlib/adapters.py +94 -0
- scholarlib/apis/__init__.py +0 -0
- scholarlib/apis/core_api.py +41 -0
- scholarlib/apis/crossref.py +93 -0
- scholarlib/apis/openaire.py +88 -0
- scholarlib/apis/openalex.py +199 -0
- scholarlib/apis/orcid.py +118 -0
- scholarlib/apis/semantic_scholar.py +99 -0
- scholarlib/apis/unpaywall.py +47 -0
- scholarlib/apis/zotero_local.py +156 -0
- scholarlib/cli/__init__.py +0 -0
- scholarlib/cli/jstor_index.py +165 -0
- scholarlib/cli/litreview.py +326 -0
- scholarlib/cli/scholarfocus.py +662 -0
- scholarlib/config.example.yaml +68 -0
- scholarlib/config.py +172 -0
- scholarlib/dedup.py +177 -0
- scholarlib/http/__init__.py +0 -0
- scholarlib/http/base.py +212 -0
- scholarlib/http/budget.py +162 -0
- scholarlib/http/cache.py +144 -0
- scholarlib/http/policy.py +59 -0
- scholarlib/jstor/__init__.py +0 -0
- scholarlib/jstor/build.py +336 -0
- scholarlib/jstor/indexes.sql +8 -0
- scholarlib/jstor/query.py +295 -0
- scholarlib/jstor/schema.sql +48 -0
- scholarlib/pipeline/__init__.py +0 -0
- scholarlib/pipeline/abstracts.py +59 -0
- scholarlib/pipeline/cluster.py +117 -0
- scholarlib/pipeline/context.py +92 -0
- scholarlib/pipeline/disambiguate.py +377 -0
- scholarlib/pipeline/jstor_coverage.py +137 -0
- scholarlib/pipeline/oa_links.py +61 -0
- scholarlib/pipeline/profile.py +333 -0
- scholarlib/pipeline/rank.py +139 -0
- scholarlib/pipeline/seed.py +97 -0
- scholarlib/pipeline/snowball.py +120 -0
- scholarlib/progress.py +131 -0
- scholarlib/records.py +202 -0
- scholarlib/render/__init__.py +0 -0
- scholarlib/render/bib.py +139 -0
- scholarlib/render/json_out.py +120 -0
- scholarlib/render/narrative.py +167 -0
- scholarlib/render/systematic.py +93 -0
- scholarlib-0.3.0.dist-info/METADATA +232 -0
- scholarlib-0.3.0.dist-info/RECORD +52 -0
- scholarlib-0.3.0.dist-info/WHEEL +5 -0
- scholarlib-0.3.0.dist-info/entry_points.txt +4 -0
- scholarlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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()
|