paperstack-cli 0.1.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.
- paperstack/__init__.py +1 -0
- paperstack/citations.py +97 -0
- paperstack/cli.py +779 -0
- paperstack/content/__init__.py +1 -0
- paperstack/content/arxiv_pdf.py +103 -0
- paperstack/content/arxiv_source.py +440 -0
- paperstack/content/vendor/latexpand +736 -0
- paperstack/content/vendor/latexpand.LICENSE +31 -0
- paperstack/dblp_index.py +568 -0
- paperstack/entrypoint.py +20 -0
- paperstack/metadata.py +392 -0
- paperstack_cli-0.1.0.dist-info/METADATA +203 -0
- paperstack_cli-0.1.0.dist-info/RECORD +15 -0
- paperstack_cli-0.1.0.dist-info/WHEEL +4 -0
- paperstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
paperstack/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Paperstack command-line package."""
|
paperstack/citations.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Batch citation-count updates for the review corpus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import metadata
|
|
13
|
+
|
|
14
|
+
S2_BATCH_API = "https://api.semanticscholar.org/graph/v1/paper/batch"
|
|
15
|
+
BATCH_SIZE = 500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def arxiv_id(raw: object) -> str | None:
|
|
19
|
+
"""Return an unversioned arXiv ID from a CURIE or arxiv.org URL."""
|
|
20
|
+
value = str(raw or "").strip()
|
|
21
|
+
match = re.fullmatch(r"arxiv:([^?#]+)", value, re.IGNORECASE)
|
|
22
|
+
if not match:
|
|
23
|
+
match = re.search(r"arxiv\.org/(?:abs|pdf)/([^?#]+?)(?:\.pdf)?(?:[?#]|$)", value, re.IGNORECASE)
|
|
24
|
+
if not match:
|
|
25
|
+
return None
|
|
26
|
+
value = re.sub(r"v\d+$", "", match.group(1))
|
|
27
|
+
try:
|
|
28
|
+
return metadata.PaperRef.parse(f"arxiv:{value}").value
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def collect(entries: list[dict]) -> list[str]:
|
|
34
|
+
return sorted({paper_id for entry in entries if (paper_id := arxiv_id(entry.get("id")))})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch(arxiv_ids: list[str]) -> dict[str, int]:
|
|
38
|
+
"""Fetch citation counts in aligned Semantic Scholar batches."""
|
|
39
|
+
counts: dict[str, int] = {}
|
|
40
|
+
headers = {"Content-Type": "application/json"}
|
|
41
|
+
if api_key := os.environ.get("SEMANTIC_SCHOLAR_API_KEY"):
|
|
42
|
+
headers["x-api-key"] = api_key
|
|
43
|
+
|
|
44
|
+
for start in range(0, len(arxiv_ids), BATCH_SIZE):
|
|
45
|
+
batch = arxiv_ids[start : start + BATCH_SIZE]
|
|
46
|
+
query = urllib.parse.urlencode({"fields": "citationCount"})
|
|
47
|
+
payload = json.dumps({"ids": [f"ARXIV:{paper_id}" for paper_id in batch]}).encode()
|
|
48
|
+
response = json.loads(metadata.request(f"{S2_BATCH_API}?{query}", headers=headers, data=payload))
|
|
49
|
+
if not isinstance(response, list):
|
|
50
|
+
detail = response.get("error") if isinstance(response, dict) else None
|
|
51
|
+
raise TypeError(f"unexpected Semantic Scholar batch response{f': {detail}' if detail else ''}")
|
|
52
|
+
for paper_id, paper in zip(batch, response, strict=True):
|
|
53
|
+
if paper is not None and not isinstance(paper, dict):
|
|
54
|
+
raise TypeError("unexpected paper in Semantic Scholar batch response")
|
|
55
|
+
if paper is not None and isinstance(paper.get("citationCount"), int):
|
|
56
|
+
counts[paper_id] = paper["citationCount"]
|
|
57
|
+
return counts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load(path: Path) -> dict:
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
return {"last_updated": None, "papers": {}}
|
|
63
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
if not isinstance(value, dict) or not isinstance(value.get("papers"), dict):
|
|
65
|
+
raise TypeError(f"{path} must contain a papers object")
|
|
66
|
+
last_updated = value.get("last_updated")
|
|
67
|
+
if last_updated is not None and not isinstance(last_updated, str):
|
|
68
|
+
raise TypeError(f"{path} last_updated must be a string or null")
|
|
69
|
+
papers = value["papers"]
|
|
70
|
+
if any(
|
|
71
|
+
not isinstance(paper_id, str) or not isinstance(count, int) or isinstance(count, bool) or count < 0
|
|
72
|
+
for paper_id, count in papers.items()
|
|
73
|
+
):
|
|
74
|
+
raise TypeError(f"{path} papers must map IDs to non-negative integers")
|
|
75
|
+
return {"last_updated": last_updated, "papers": papers}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def update(root: Path, entries: list[dict], *, live: bool) -> tuple[dict, int]:
|
|
79
|
+
"""Refresh or prune citation data and return the document and change count."""
|
|
80
|
+
path = root / "citations.json"
|
|
81
|
+
cached = load(path)
|
|
82
|
+
cached_papers = cached["papers"]
|
|
83
|
+
paper_ids = collect(entries)
|
|
84
|
+
fetched = fetch(paper_ids) if live else {}
|
|
85
|
+
papers = {
|
|
86
|
+
paper_id: fetched.get(paper_id, cached_papers.get(paper_id))
|
|
87
|
+
for paper_id in paper_ids
|
|
88
|
+
if paper_id in fetched or paper_id in cached_papers
|
|
89
|
+
}
|
|
90
|
+
changed = sum(cached_papers.get(paper_id) != count for paper_id, count in papers.items())
|
|
91
|
+
changed += sum(paper_id not in papers for paper_id in cached_papers)
|
|
92
|
+
document = {
|
|
93
|
+
"last_updated": datetime.now(UTC).date().isoformat() if live and fetched else cached["last_updated"],
|
|
94
|
+
"papers": papers,
|
|
95
|
+
}
|
|
96
|
+
path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
97
|
+
return document, changed
|