Files
Cormac Shannon 95b5857189 Add literature review protocol, searches and first snowball round
Six seeds snowballed (Semantic Scholar) plus logged keyword searches
(OpenAlex, arXiv): 2,127 unique records, 480 title/abstract screened.
AI first-pass suggestions are in their own column; decisions are pending.

Claude-Session: https://claude.ai/code/session_01S8KN7NuUCm8nXLmy6eDuVv
2026-10-28 18:00:06 +00:00

156 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Tiny Semantic Scholar Graph API client for the literature search (stdlib only).
Every API response is cached under ../search/.cache/ so re-running a search is
free and reproducible, and the raw JSON is kept as an audit trail.
Usage:
python3 s2.py search "query string" [--year 2020-] [--limit 100] [--out NAME]
python3 s2.py refs ARXIV_ID_OR_S2_ID [--out NAME] # backward snowball
python3 s2.py cites ARXIV_ID_OR_S2_ID [--out NAME] # forward snowball
Output: a CSV in ../search/results/ (one row per paper) and a line appended
to ../search/search-log.csv recording what was run, when, and how many hits.
Set S2_API_KEY in the environment if you get one (free, raises rate limits).
"""
import csv
import datetime as dt
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
SEARCH_DIR = os.path.normpath(os.path.join(HERE, "..", "search"))
CACHE = os.path.join(SEARCH_DIR, ".cache")
RESULTS = os.path.join(SEARCH_DIR, "results")
LOG = os.path.join(SEARCH_DIR, "search-log.csv")
API = "https://api.semanticscholar.org/graph/v1"
FIELDS = "paperId,externalIds,title,year,venue,authors,citationCount,abstract,publicationDate"
def get(path, params):
url = f"{API}/{path}?{urllib.parse.urlencode(params)}"
key = hashlib.sha1(url.encode()).hexdigest()
cpath = os.path.join(CACHE, key + ".json")
if os.path.exists(cpath):
return json.load(open(cpath))
headers = {"User-Agent": "msc-thesis-lit-search/0.1"}
if os.environ.get("S2_API_KEY"):
headers["x-api-key"] = os.environ["S2_API_KEY"]
for attempt in range(int(os.environ.get("S2_RETRIES", 8))):
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as r:
data = json.load(r)
os.makedirs(CACHE, exist_ok=True)
json.dump(data, open(cpath, "w"))
time.sleep(1.2) # stay under the shared rate limit
return data
except urllib.error.HTTPError as e:
if e.code == 404:
return None
if e.code not in (429, 500, 502, 503, 504):
raise
except (urllib.error.URLError, TimeoutError):
pass
wait = min(60, 3 * 2 ** attempt)
print(f" rate-limited/transient error; retry in {wait}s", file=sys.stderr)
time.sleep(wait)
sys.exit(f"gave up on {url}")
def norm_id(pid):
if pid[:1].isdigit() and "." in pid: # bare arXiv id like 2309.14322
return "arXiv:" + pid
return pid
def row(p):
ext = p.get("externalIds") or {}
return {
"s2_id": p.get("paperId"),
"arxiv": ext.get("ArXiv", ""),
"doi": ext.get("DOI", ""),
"year": p.get("year") or "",
"title": " ".join((p.get("title") or "").split()),
"first_author": ((p.get("authors") or [{}])[0] or {}).get("name", ""),
"n_authors": len(p.get("authors") or []),
"venue": p.get("venue") or "",
"citations": p.get("citationCount") or 0,
"abstract": " ".join((p.get("abstract") or "").split()),
}
def search(query, year=None, limit=100):
"""Relevance-ranked search (top `limit` hits)."""
out, offset, total = [], 0, 0
while len(out) < limit:
params = {"query": query, "fields": FIELDS, "offset": offset, "limit": min(100, limit - len(out))}
if year:
params["year"] = year
data = get("paper/search", params)
out += data.get("data") or []
total = data.get("total", len(out))
if "next" not in data:
break
offset = data["next"]
return out[:limit], total
def snowball(pid, direction):
field = "citedPaper" if direction == "refs" else "citingPaper"
path = f"paper/{norm_id(pid)}/{'references' if direction == 'refs' else 'citations'}"
out, offset = [], 0
while True:
data = get(path, {"fields": FIELDS, "limit": 500, "offset": offset})
if data is None:
sys.exit(f"paper not found: {pid}")
out += [d[field] for d in data.get("data") or [] if d.get(field, {}).get("title")]
if "next" not in data:
return out, len(out)
offset = data["next"]
def write(name, papers, what, total):
os.makedirs(RESULTS, exist_ok=True)
rows = [row(p) for p in papers]
path = os.path.join(RESULTS, name + ".csv")
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["s2_id"])
w.writeheader()
w.writerows(rows)
new = not os.path.exists(LOG)
with open(LOG, "a", newline="") as f:
w = csv.writer(f)
if new:
w.writerow(["date", "source", "action", "query_or_seed", "filters", "total_hits", "retrieved", "results_file"])
w.writerow([dt.date.today().isoformat(), "Semantic Scholar API", *what, total, len(rows), os.path.relpath(path, SEARCH_DIR)])
print(f"{what[0]} '{what[1]}': {total} total, {len(rows)} saved -> {os.path.relpath(path, SEARCH_DIR)}")
def main(a):
def opt(flag, default=None):
return a[a.index(flag) + 1] if flag in a else default
cmd, arg = a[0], a[1]
out = opt("--out", hashlib.sha1(f"{cmd}{arg}".encode()).hexdigest()[:8])
if cmd == "search":
year = opt("--year")
papers, total = search(arg, year, int(opt("--limit", 100)))
write(out, papers, ("search", arg, f"year={year or 'any'}"), total)
elif cmd in ("refs", "cites"):
papers, total = snowball(arg, cmd)
label = "backward snowball (references)" if cmd == "refs" else "forward snowball (citations)"
write(out, papers, (label, arg, ""), total)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main(sys.argv[1:])