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 ADDED
@@ -0,0 +1 @@
1
+ """Paperstack command-line package."""
@@ -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