Spaces:
Running
Running
File size: 14,264 Bytes
6153124 93304a4 6153124 189785a 6153124 a5be607 6153124 a5be607 6153124 68075ae 6153124 189785a a5be607 6153124 a2eedb9 189785a a5be607 6153124 a2eedb9 6153124 a2eedb9 6153124 a5be607 6153124 a5be607 6153124 93304a4 6153124 93304a4 6153124 93304a4 6153124 93304a4 6153124 93304a4 6153124 93304a4 6153124 a5be607 6153124 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | #!/usr/bin/env python3
"""
Fetch current citation counts from Semantic Scholar into a staging SQLite file.
Why this exists
---------------
Citation counts were collected once, with the Kaggle snapshot. Every paper
ingested since (all 202,251 from 2025-06 onward) arrived with 0, and the
existing ones have not moved since. Tier 0 ranks new readers' feeds by these
counts, so measured on 2026-09-30 it could not surface a single paper from the
last 14 months: 2506.05176 had 1,488 citations on Semantic Scholar and 0 here.
This script only READS Semantic Scholar and writes a local staging file.
Applying the counts to Turso and the sidecar is a separate, explicit step
(`--apply-sidecar`, `--apply-turso`), so a fetch can never touch production.
Usage
-----
# fetch, newest papers first; resumable, safe to interrupt
python scripts/refresh_citations.py fetch --sidecar data/metadata.sqlite --staging data/citations.sqlite
# report what would change
python scripts/refresh_citations.py diff --sidecar data/metadata.sqlite --staging data/citations.sqlite
# write the counts into a sidecar file (edit a COPY; the image ships it)
python scripts/refresh_citations.py apply-sidecar --sidecar data/metadata.sqlite --staging data/citations.sqlite
# write counts that differ from what Turso holds now (production; deliberate)
python scripts/refresh_citations.py apply-turso --staging data/citations.sqlite --dry-run
python scripts/refresh_citations.py apply-turso --staging data/citations.sqlite
S2_API_KEY is used when set and valid, throttled to its 1 request/second limit
(500 papers per request). Without it the shared public pool is used, with
backoff on 429; that pool gave up mid-run twice on 2026-09-30.
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import sys
import threading
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
import httpx
API = "https://api.semanticscholar.org/graph/v1/paper/batch"
BATCH = 500 # the batch endpoint's maximum
def _staging(path: str) -> sqlite3.Connection:
# URI mode, so the read-only `file:...?mode=ro` ATTACH in diff/apply-turso
# is honoured; a plain connection rejects it as "unable to open database".
db = sqlite3.connect(f"file:{os.path.abspath(path)}", uri=True)
db.execute("""CREATE TABLE IF NOT EXISTS citations (
arxiv_id TEXT PRIMARY KEY, citation_count INTEGER, influential_citations INTEGER,
found INTEGER NOT NULL, fetched_at TEXT NOT NULL)""")
return db
_KEY = {"value": os.getenv("S2_API_KEY") or None}
# A key is limited to 1 request/second across all endpoints; stay under it.
_KEYED_INTERVAL_S = 1.1
_last = {"t": 0.0}
_rate_lock = threading.Lock()
def _post(client: httpx.Client, ids: list[str]) -> list | None:
headers = {"x-api-key": _KEY["value"]} if _KEY["value"] else {}
codes = []
for attempt in range(14): # ~10 min of backoff: S2 has multi-minute 429/5xx bursts
if headers:
# Space request STARTS, across worker threads: the limit is on the
# request rate, and a 500-paper batch takes ~6 s to answer.
with _rate_lock:
wait = _last["t"] + _KEYED_INTERVAL_S - time.monotonic()
if wait > 0:
time.sleep(wait)
_last["t"] = time.monotonic()
try:
r = client.post(API, params={"fields": "citationCount,influentialCitationCount"},
headers=headers, json={"ids": [f"ARXIV:{i}" for i in ids]})
except httpx.HTTPError as e:
print(f"[citations] network error ({e}); retrying", file=sys.stderr)
r = None
if r is not None and r.status_code == 200:
return r.json()
codes.append(r.status_code if r is not None else "net")
if r is not None and r.status_code in (401, 403) and headers:
print("[citations] S2_API_KEY rejected; using the public pool from now on", file=sys.stderr)
_KEY["value"], headers = None, {}
continue
time.sleep(min(60, 2 ** attempt))
print(f"[citations] batch failed; statuses seen: {codes}", file=sys.stderr)
return None
def fetch(args) -> int:
src = sqlite3.connect(f"file:{os.path.abspath(args.sidecar)}?mode=ro", uri=True)
db = _staging(args.staging)
done = {r[0] for r in db.execute("SELECT arxiv_id FROM citations")}
# Newest first. New-style ids (YYMM.NNNNN) sort by date; old-style ids
# (hep-th/9901001) predate 2007 and would sort ahead of them on letters alone.
ids = [r[0] for r in src.execute(
"SELECT arxiv_id FROM papers ORDER BY arxiv_id GLOB '[0-9]*' DESC, arxiv_id DESC")
if r[0] not in done]
if args.since:
ids = [i for i in ids if i[:4].isdigit() and i >= args.since]
print(f"[citations] {len(ids):,} to fetch ({len(done):,} already staged)")
t0 = time.time()
chunks = [ids[i:i + BATCH] for i in range(0, len(ids), BATCH)]
workers = max(1, args.workers) if _KEY["value"] else 1
with httpx.Client(timeout=120) as client, ThreadPoolExecutor(workers) as pool:
# A bounded window of in-flight requests, consumed in submission order:
# results are staged in order, and on a failure at most `workers`
# requests are still outstanding (Executor.map would queue them all).
pending: deque = deque()
nxt = 0
for n in range(len(chunks)):
while nxt < len(chunks) and len(pending) < workers:
pending.append(pool.submit(_post, client, chunks[nxt]))
nxt += 1
chunk, res = chunks[n], pending.popleft().result()
i = n * BATCH
if res is None:
for f in pending:
f.cancel()
print(f"[citations] giving up on batch at {chunk[0]}; re-run to resume", file=sys.stderr)
return 1
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
db.executemany(
"INSERT OR REPLACE INTO citations VALUES (?,?,?,?,?)",
[(a, (p or {}).get("citationCount"), (p or {}).get("influentialCitationCount"),
int(p is not None), now) for a, p in zip(chunk, res)])
db.commit()
if n % 20 == 0:
rate = (i + len(chunk)) / max(1e-9, time.time() - t0)
print(f"[citations] {i + len(chunk):,}/{len(ids):,} at {chunk[-1]} "
f"({rate:.0f}/s, ~{(len(ids) - i) / max(rate, 1e-9) / 60:.0f} min left)", flush=True)
print(f"[citations] done in {(time.time() - t0) / 60:.1f} min")
return 0
def diff(args) -> int:
db = _staging(args.staging)
db.execute(f"ATTACH DATABASE 'file:{os.path.abspath(args.sidecar)}?mode=ro' AS s")
rows = db.execute("""SELECT substr(c.arxiv_id,1,2) yy, count(*), sum(c.found),
sum(c.citation_count > coalesce(p.citation_count,0)),
sum(coalesce(p.citation_count,0) = 0 AND c.citation_count > 0)
FROM citations c JOIN s.papers p USING (arxiv_id)
GROUP BY yy ORDER BY yy DESC""").fetchall()
print("year staged found increased was-0-now->0")
for yy, n, found, inc, zero in rows:
print(f"20{yy} {n:>7,} {found:>7,} {inc:>10,} {zero:>13,}")
return 0
def _changed(db: sqlite3.Connection) -> str:
"""Staged rows whose count differs from the sidecar's. Papers S2 does not
know keep their stored counts: absent is not zero."""
return """SELECT c.arxiv_id, c.citation_count, coalesce(c.influential_citations, 0)
FROM citations c JOIN s.papers p USING (arxiv_id)
WHERE c.found = 1 AND c.citation_count IS NOT NULL
AND (c.citation_count IS NOT coalesce(p.citation_count, 0)
OR coalesce(c.influential_citations, 0) IS NOT coalesce(p.influential_citations, 0))"""
def apply_sidecar(args) -> int:
db = sqlite3.connect(os.path.abspath(args.sidecar))
db.execute(f"ATTACH DATABASE '{os.path.abspath(args.staging)}' AS st")
with db:
db.execute("""CREATE TEMP TABLE upd AS
SELECT c.arxiv_id, c.citation_count AS cit, coalesce(c.influential_citations, 0) AS inf
FROM st.citations c WHERE c.found = 1 AND c.citation_count IS NOT NULL""")
db.execute("CREATE INDEX temp.upd_id ON upd(arxiv_id)")
n = db.execute("""UPDATE papers SET citation_count = upd.cit, influential_citations = upd.inf
FROM upd WHERE papers.arxiv_id = upd.arxiv_id""").rowcount
m = db.execute("""UPDATE paper_categories SET citation_count = upd.cit
FROM upd WHERE paper_categories.arxiv_id = upd.arxiv_id""").rowcount
print(f"[citations] sidecar: {n:,} papers and {m:,} category rows updated")
return 0
def diff_against(current: dict[str, tuple[int, int]], staged) -> list[tuple[str, int, int]]:
"""(arxiv_id, citations, influential) for staged rows that differ from
`current`. Unknown papers (found=0 or no count) keep their stored values,
and ids absent from `current` are skipped: they are not rows to update."""
out = []
for aid, cit, inf, found in staged:
if not found or cit is None or aid not in current:
continue
new = (int(cit), int(inf or 0))
if current[aid] != new:
out.append((aid, *new))
return out
def _turso_current(client: httpx.Client, url: str, token: str, page: int = 10_000) -> dict:
"""Every paper's stored counts, read from Turso itself (keyset-paged on the
primary key), so the diff is against what is there now, not a proxy."""
current, last = {}, ""
while True:
stmt = {"sql": "SELECT arxiv_id, citation_count, influential_citations FROM papers "
"WHERE arxiv_id > ? ORDER BY arxiv_id LIMIT ?",
"args": [{"type": "text", "value": last}, {"type": "integer", "value": str(page)}]}
r = client.post(f"{url}/v2/pipeline", headers={"Authorization": f"Bearer {token}"},
json={"requests": [{"type": "execute", "stmt": stmt}, {"type": "close"}]})
r.raise_for_status()
rows = r.json()["results"][0]["response"]["result"]["rows"]
if not rows:
return current
for a, c, f in ([x.get("value") for x in row] for row in rows):
current[a] = (int(c or 0), int(f or 0))
last = rows[-1][0]["value"]
def apply_turso(args) -> int:
url = os.environ.get("TURSO_URL", "").replace("libsql://", "https://").rstrip("/")
token = os.environ.get("TURSO_DB_TOKEN", "")
if not url or not token:
print("TURSO_URL and TURSO_DB_TOKEN must be set", file=sys.stderr)
return 2
db = _staging(args.staging)
staged = db.execute("SELECT arxiv_id, citation_count, influential_citations, found FROM citations")
t0 = time.time()
with httpx.Client(timeout=300) as client:
current = _turso_current(client, url, token)
rows = diff_against(current, staged)
print(f"[citations] read {len(current):,} Turso rows in {time.time() - t0:.0f}s; "
f"{len(rows):,} differ from the staged counts", flush=True)
if args.dry_run or not rows:
return 0
sql = "UPDATE papers SET citation_count = ?, influential_citations = ? WHERE arxiv_id = ?"
def write(chunk):
stmts = [{"type": "execute", "stmt": {"sql": sql, "args": [
{"type": "integer", "value": str(c)}, {"type": "integer", "value": str(f)},
{"type": "text", "value": a}]}} for a, c, f in chunk]
r = client.post(f"{url}/v2/pipeline", headers={"Authorization": f"Bearer {token}"},
json={"requests": stmts + [{"type": "close"}]})
r.raise_for_status()
errs = [x for x in r.json().get("results", []) if x.get("type") == "error"]
if errs:
raise RuntimeError(str(errs[0])[:200])
return len(chunk)
chunks = [rows[i:i + args.batch] for i in range(0, len(rows), args.batch)]
done, t1 = 0, time.time()
with ThreadPoolExecutor(max(1, args.workers)) as pool:
pending: deque = deque()
nxt = 0
for n in range(len(chunks)):
while nxt < len(chunks) and len(pending) < args.workers:
pending.append(pool.submit(write, chunks[nxt]))
nxt += 1
done += pending.popleft().result() # raises on a failed batch
if n % 20 == 0:
rate = done / max(1e-9, time.time() - t1)
print(f"[citations] turso {done:,}/{len(rows):,} ({rate:.0f}/s, "
f"~{(len(rows) - done) / max(rate, 1e-9) / 60:.0f} min left)", flush=True)
print(f"[citations] turso done: {len(rows):,} rows in {(time.time() - t0) / 60:.1f} min")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
for name in ("fetch", "diff", "apply-sidecar", "apply-turso"):
p = sub.add_parser(name)
p.add_argument("--sidecar", required=(name != "apply-turso"),
help="metadata sqlite to read ids/current counts from")
p.add_argument("--staging", required=True, help="where fetched counts are written")
if name == "apply-turso":
p.add_argument("--dry-run", action="store_true", help="only count the rows that differ")
p.add_argument("--batch", type=int, default=1000, help="UPDATEs per Turso request")
p.add_argument("--workers", type=int, default=4, help="Turso requests in flight")
if name == "fetch":
p.add_argument("--since", default="", help="only ids >= this prefix, e.g. 2506")
p.add_argument("--workers", type=int, default=4,
help="requests in flight with a key (starts stay 1.1 s apart)")
args = ap.parse_args()
return {"fetch": fetch, "diff": diff, "apply-sidecar": apply_sidecar,
"apply-turso": apply_turso}[args.cmd](args)
if __name__ == "__main__":
sys.exit(main())
|