
Semanticscholar Skill
- 2.3k installs
- 29 repo stars
- Updated August 2, 2026
- agents365-ai/365-skills
semanticscholar-skill queries Semantic Scholar for papers, citations, authors, and recommendations via s2.py.
About
The semanticscholar-skill runs a four-phase workflow: plan strategy, execute one Python script with s2.py prelude, deduplicate results, and present findings. Critical rule forbids sequential Bash API calls; all searches run in a single script with built-in rate limiting. Strategies map to search_bulk, search_relevance, search_snippets, match_title, get_paper, citations, recommendations, and author endpoints. build_bool_query helps disambiguate terms; filters cover year, venue, citations, open access, and publication types. S2_API_KEY optional for higher limits. Agents default to bulk search unless TLDR or inline author details require relevance mode. Use for literature discovery, citation analysis, paper lookup by DOI, or researcher publication lists.
- Runs all API calls in one Python script via s2.py helpers.
- Defaults to search_bulk over heavier relevance search.
- Supports boolean queries, filters, and multi-search deduplication.
- Optional S2_API_KEY for improved Semantic Scholar rate limits.
- Maps user intent through decision tree to correct s2 function.
Semanticscholar Skill by the numbers
- 2,321 all-time installs (skills.sh)
- +130 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #169 of 1,879 Documentation skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
semanticscholar-skill capabilities & compatibility
- Capabilities
- bulk paper search · citation traversal · author lookup
- Use cases
- research · web search
What semanticscholar-skill says it does
Search academic papers via the Semantic Scholar API using a structured 4-phase workflow.
npx skills add https://github.com/agents365-ai/365-skills --skill semanticscholar-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.3k |
|---|---|
| repo stars | ★ 29 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 2, 2026 |
| Repository | agents365-ai/365-skills ↗ |
How do I search academic papers and citations programmatically?
Search academic papers, citations, authors, and recommendations via Semantic Scholar API using one bundled Python script per query batch.
Who is it for?
Literature reviews, citation lookup, and academic research agents.
Skip if: Paywalled PDF full-text extraction without open access links.
When should I use this skill?
User searches research papers, citations, Semantic Scholar, or literature.
What you get
Deduplicated paper results from a single scripted Semantic Scholar query batch.
- Ranked paper lists
- Boolean query strings
- Deduplicated citation records
By the numbers
- Organizes API helpers into a 4-phase workflow
- search_bulk supports boolean queries with up to 10M result capacity
Files
Semantic Scholar Search Workflow
Search academic papers via the Semantic Scholar API using a structured 4-phase workflow.
Critical rule: NEVER make multiple sequential Bash calls for API requests. Always write ONE Python script that runs all searches, then execute it once. All rate limiting is handled inside s2.py automatically.
Phase 1: Understand & Plan
Parse the user's intent and choose a search strategy:
Decision Tree
Default to `search_bulk()`. Per Semantic Scholar's own docs, bulk search is preferred over relevance search for most cases because relevance search is more resource-intensive. Use search_relevance() only when you need TLDR fields or author/citation details inline.| User wants... | Strategy | Function |
|---|---|---|
| Broad topic exploration | Bulk search (preferred) | search_bulk() with build_bool_query() |
| Need TLDR / inline author details | Relevance search | search_relevance() |
| Precise technical terms, exact phrases | Bulk search with boolean operators | search_bulk() with build_bool_query() |
| Specific passages or methods | Snippet search | search_snippets() |
| Known paper by title | Title match | match_title() |
| Known paper by DOI/PMID/ArXiv | Direct lookup | get_paper() |
| Papers citing a known work | Citation traversal | get_citations() |
| Related to one paper | Single-seed recommendations | find_similar() |
| Related to multiple papers | Multi-seed recommendations | recommend() |
| Find a researcher | Author search | search_authors() |
| Researcher's profile | Author details | get_author() |
| Researcher's publications | Author papers | get_author_papers() |
Query Construction Rules
- Ambiguous terms (e.g., "stem cells" could mean mesenchymal or stem-like T cells): Use
build_bool_query()with exact phrases and exclusions - Example:
build_bool_query(phrases=["stem-like T cells"], required=["CD4", "TCF7"], excluded=["mesenchymal", "hematopoietic stem cell"]) - Multi-context queries (e.g., "topic X in cancer AND autoimmunity"): Plan separate searches, deduplicate with
deduplicate() - Broad topics: Use
search_relevance()with filters (year, venue, fieldsOfStudy, minCitationCount)
Plan Filters
| Filter | Use when |
|---|---|
year="2020-" | Recent work only |
publication_date="2024-01-01:2024-06-30" | Precise date range (YYYY-MM-DD) |
fields_of_study="Medicine" | Restrict to domain |
min_citations=10 | Only established papers |
pub_types="Review" | Find reviews/meta-analyses |
pub_types="ClinicalTrial" | Clinical trials only |
open_access=True | Only open access papers |
Checkpoint: Before proceeding, verify: (1) search strategy matches user intent, (2) filters are appropriate, (3) query is specific enough to avoid irrelevant results.
Phase 2: Execute Search
Write ONE Python script that begins with the standard prelude below, then runs all searches:
# --- Standard prelude (use in every script) ---
import sys, os, glob
_candidates = [
os.path.expanduser("~/.claude/skills/semanticscholar-skill"),
os.path.expanduser("~/.openclaw/skills/semanticscholar-skill"),
*glob.glob(os.path.expanduser("~/.claude/plugins/**/semanticscholar-skill"), recursive=True),
*glob.glob(os.path.expanduser("~/.codex/skills/semanticscholar-skill")),
".",
]
SKILL_DIR = next((p for p in _candidates if os.path.isfile(os.path.join(p, "s2.py"))), None)
if SKILL_DIR is None:
raise RuntimeError("Cannot locate semanticscholar-skill (s2.py not found)")
sys.path.insert(0, SKILL_DIR)
from s2 import *
# --- end prelude ---
# Build precise query
q = build_bool_query(
phrases=["stem-like T cells"],
required=["CD4", "IBD"],
excluded=["mesenchymal"]
)
papers = search_bulk(q, max_results=30, year="2018-", fields_of_study="Medicine")
papers = deduplicate(papers)
print(format_results(papers, "Stem-like CD4 T cells in IBD"))Save to /tmp/s2_search.py, then run with python3 /tmp/s2_search.py in a single Bash call. Rate limiting, retries, and backoff are automatic inside s2.py.
No API key: The skill works without S2_API_KEY. When the key is absent or invalid, s2.py automatically switches to unauthenticated mode (no x-api-key header) and widens the request gap to 5 s. Per S2 docs, anonymous calls share a global 1000 req/s pool across all unauthenticated users and can be "further throttled during periods of heavy use" — so a conservative 5 s gap protects against the heavy-use throttling, even though the steady-state pool is generous. If you still see sustained 429s, raise _MIN_GAP to 10 s. Keep max_results ≤ 30 per search and combine fewer searches per script. S2 recommends including an API key on every request — get one at https://www.semanticscholar.org/product/api#api-key-form.
Checkpoint: Verify the script ran successfully (no exceptions) and returned results. If 0 results, broaden the query or relax filters before presenting.
Worked Examples
Each example below assumes the standard prelude from Phase 2 is at the top of the script.
Example 1: Author workflow — "Find papers by Yann LeCun on self-supervised learning"
authors = search_authors("Yann LeCun", max_results=5)
print(format_authors(authors))
# Use the first match's ID to get their papers
author_id = authors[0]["authorId"]
papers = get_author_papers(author_id, max_results=50)
# Filter locally for topic
ssl_papers = [p for p in papers if "self-supervised" in (p.get("title") or "").lower()]
print(format_results(ssl_papers, "Yann LeCun - Self-Supervised Learning"))Example 2: Citation chain with intent — "Who cited the Transformer paper and how did they use it?"
paper = get_paper("DOI:10.48550/arXiv.1706.03762")
print(f"Title: {paper['title']}, Citations: {paper['citationCount']}")
# Citation envelopes carry contextsWithIntent — keep them, don't flatten.
citing = get_citations(paper["paperId"], max_results=50)
citing.sort(key=lambda c: (c.get("citingPaper") or {}).get("citationCount", 0), reverse=True)
print(format_citations(citing, max_items=10)) # renders intent labels + context snippetExample 3: Multi-seed recommendations with BibTeX export — "Find papers like these two but not about NLP"
recs = recommend(
positive_ids=["DOI:10.1038/nature14539", "ARXIV:2010.11929"],
negative_ids=["ARXIV:1706.03762"],
limit=20
)
print(format_results(recs, "Vision papers like Deep Learning & ViT, excluding NLP"))
# Export BibTeX for top results
bib_data = batch_papers([r["paperId"] for r in recs[:10]], fields="title,citationStyles")
print(export_bibtex(bib_data))Phase 3: Summarize & Present
- Use
format_results()for consistent output (summary table + top-10 details) - If user's language is Chinese, present summaries in Chinese
- Always note total results count and search strategy used
- Highlight most relevant papers based on the user's specific question
Phase 4: User Interaction Loop
After presenting results, always offer these options:
1. Translate — titles/summaries to Chinese (or other language) 2. Details — full abstract for specific paper numbers 3. Refine — narrow or expand search with different terms/filters 4. Similar — find papers similar to a specific result (find_similar()) 5. Citations — who cited a specific paper and how (get_citations() + format_citations() for intent labels) 6. Export — save results via export_bibtex(), export_markdown(), or export_json() 7. Done — end search session
Loop until user says done. Each follow-up uses the same single-script pattern.
---
Additional Resources
- S2folks GitHub — Official Semantic Scholar code examples: https://github.com/allenai/s2-folks
- Postman Collection — No-code API testing: linked from https://www.semanticscholar.org/product/api/tutorial
- API Documentation — Full endpoint reference: https://api.semanticscholar.org/
---
API Quick Reference
Helper Module (s2.py)
Use the standard prelude from Phase 2 at the top of every script. Then call any of the functions below — the module's docstring (help(s2) or read s2.py) lists each by phase with one-line summaries.
Paper Search Functions
| Function | Purpose | Max Results |
|---|---|---|
search_relevance(query, **filters) | Simple broad search | 1,000 |
search_bulk(query, sort=..., **filters) | Boolean precise search | 10,000,000 |
search_snippets(query, paper_ids=, authors=, inserted_before=, **filters) | Full-text passage search | 1,000 |
match_title(title) | Exact title match | 1 |
paper_autocomplete(query) | Query-completion suggestions | — |
get_paper(paper_id) | Single paper details | — |
get_citations(paper_id, max_results, publication_date=) | Who cited this | 10,000 |
get_references(paper_id, max_results) | What this cites | 10,000 |
find_similar(paper_id, limit, pool) | Single-seed recommendations | 500 |
recommend(positive_ids, negative_ids, limit) | Multi-seed recommendations | 500 |
batch_papers(ids, fields) | Batch lookup (≤500) | — |
Author Functions
| Function | Purpose | Max Results |
|---|---|---|
search_authors(query, max_results) | Find researchers by name | 1,000 |
get_author(author_id) | Author profile (affiliations, h-index) | — |
get_author_papers(author_id, max_results, publication_date=) | Author's publications | 10,000 |
get_paper_authors(paper_id, max_results) | Paper's author list | 1,000 |
batch_authors(ids, fields) | Batch author lookup (≤1000) | — |
Filter Parameters (kwargs)
snake_case kwargs are translated to S2 camelCase params automatically (fields_of_study → fieldsOfStudy, min_citations → minCitationCount, publication_date → publicationDateOrYear, pub_types → publicationTypes, open_access → openAccessPdf). Use snake_case here.
year, publication_date, venue, fields_of_study, min_citations, pub_types, open_access
year:"2020-","-2019","2016-2020"publication_date:"2024-01-01:2024-06-30"(YYYY-MM-DD range, open-ended OK)pub_types:Review,JournalArticle,Conference,ClinicalTrial,MetaAnalysis,Dataset,Book,CaseReport,Editorial,LettersAndComments,News,Study,BookSection
Boolean Query Syntax (bulk search only)
| Syntax | Example | Meaning |
|---|---|---|
"..." | "deep learning" | Exact phrase |
+ | +transformer | Must include |
- | -survey | Exclude |
| `\ | ` | `CNN \ |
* | neuro* | Prefix wildcard |
() | `(CNN \ | RNN) +attention` |
term~N | bugs~3 | Fuzzy: matches words within N edits (e.g. buggy, buns) |
"phrase"~N | "blue lake"~3 | Proximity: up to N words between terms |
Use build_bool_query(phrases, required, excluded, or_terms, fuzzy, proximity) to construct safely.
fuzzy: list of(term, edit_distance)tuplesproximity: list of(phrase, word_distance)tuples
Output Functions
| Function | Purpose |
|---|---|
format_table(papers, max_rows=30) | Markdown summary table |
format_details(papers, max_papers=10) | Detailed entries with TLDR/abstract |
format_citations(citations, max_items=10) | Citation envelopes with intent labels + context snippet |
format_results(papers, query_desc) | Combined: summary + table + details |
format_authors(authors, max_rows=20) | Author table (name, affiliations, h-index) |
export_bibtex(papers) | BibTeX entries (requires citationStyles field) |
export_markdown(papers, query_desc) | Full markdown report saved to file |
export_json(papers, path) | JSON export saved to file |
deduplicate(papers) | Remove duplicates by paperId |
Supported ID Formats
DOI:10.1038/..., ARXIV:2106.15928, PMID:19872477, PMCID:PMC2323569, CorpusId:215416146, ACL:2020.acl-main.447, DBLP:conf/acl/..., MAG:3015453090, URL:https://...
Paper Fields
Default: title,year,citationCount,authors,venue,externalIds,tldr
Additional: corpusId (integer, S2 secondary ID), url (S2 paper page link), abstract, references, citations, openAccessPdf, publicationDate, publicationVenue, fieldsOfStudy, s2FieldsOfStudy, journal, isOpenAccess, referenceCount, influentialCitationCount (influential citations only), citationStyles, embedding, textAvailability
externalIds object contains: ArXiv, MAG, ACL, PubMed, Medline, PubMedCentral, DBLP, DOI
Author fields: name, affiliations, paperCount, citationCount, hIndex, homepage, externalIds, papers
Minimize fields. Per the official S2 tutorial: "Avoid including more fields than you need, because that can slow down the response rate." Only addabstract,references, orcitationswhen the user explicitly needs them.
sort Parameter Values (bulk search only)
The sort kwarg accepts only these three values:
| Value | Meaning |
|---|---|
citationCount:desc | Most-cited first (default) |
publicationDate:desc | Newest first |
paperId:asc | Stable deterministic order (useful for pagination) |
Recommendations Limits
find_similar() and recommend() return at most 500 papers per call (limit max = 500).
Datasets API Functions
For bulk download of full S2 datasets (papers, authors, abstracts, embeddings, etc.):
| Function | Purpose | Requires key? |
|---|---|---|
list_releases() | List all available release date strings | No |
list_datasets(release_id="latest") | List datasets in a release | No |
get_dataset_links(release_id, dataset_name) | Pre-signed download URLs for a dataset | Yes |
get_dataset_diffs(start, end, dataset_name) | Incremental diffs between two releases | Yes |
Available dataset names (pass as dataset_name):
| Name | Description | Approx size |
|---|---|---|
papers | Core paper attributes (title, authors, date, etc.) | ~200M records, 30 × 1.5 GB |
abstracts | Paper abstract text where available | ~100M records, 30 × 1.8 GB |
authors | Author core attributes (name, affiliation, paper count) | — |
citations | Citation relationships between papers | — |
embeddings-specter_v1 | Dense SPECTER vector embeddings of papers | ~120M records, 30 × 28 GB |
publication-venues | Venue metadata | — |
s2orc | Full-body text from open-access PDFs | — |
tldrs | Short natural-language summaries | ~100M records, 30 × 200 MB |
All datasets are delivered as JSON Lines (one record per line). The diffs response contains update_files (insert/replace by primary key) and delete_files (remove from dataset).
Rate Limiting
s2.py adapts automatically based on whether S2_API_KEY is set:
| Mode | Gap | Official limit | Retries |
|---|---|---|---|
| Authenticated (valid key) | 1.1 s | Introductory 1 req/s per key, dedicated quota, cumulative across all endpoints (raisable on request) | 5× exponential backoff (2s→60s) |
| Unauthenticated (no key or invalid key) | 5.0 s | 1000 req/s shared globally across all anonymous users; "may be further throttled during periods of heavy use" | 5× exponential backoff (2s→60s) |
S2 recommends including an API key on every request, even for endpoints that work anonymously — it gives you a dedicated quota, a smoother experience under load, and better support if you need help. The introductory 1 req/s key can be raised on request. Get one at https://www.semanticscholar.org/product/api#api-key-form
>
The anonymous 1000 req/s pool is generous in steady state, but the docs explicitly warn it can be throttled hard during heavy use — that is why_MIN_GAPdefaults to 5 s without a key, not the 1 ms a 1000 req/s budget would technically allow. If your workload still hits sustained 429s, set_MIN_GAP = 10.0ins2.pyor get a key. The 1 req/s key budget is cumulative across all endpoints, so chained calls (e.g.get_paper→get_citations) count separately.
Bulk Search Response Structure
search_bulk() returns a list of papers already unpaginated. Internally the raw response has:
| Field | Type | Meaning |
|---|---|---|
total | integer | Estimated total matching papers (not exact) |
token | string | Present when more pages exist; pass in next request |
data | array | Papers for this page |
s2.py handles token pagination automatically — you only see the final flat list.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
HTTPError 403 | S2_API_KEY is set but invalid/expired | s2.py auto-falls back to unauthenticated; or unset S2_API_KEY, or get a new key at https://www.semanticscholar.org/product/api#api-key-form |
HTTPError 404 | Bad paper/author ID | Check ID format — S2 returns {"error": "Paper/Author/Object not found"} or "...with id ### not found" |
HTTPError 429 after 5 retries | Sustained anonymous rate limit hit | Wait 60 s, raise _MIN_GAP in s2.py from 5.0 → 10.0, keep max_results ≤ 30, or get an API key |
ModuleNotFoundError: s2 | Skill directory not on path | Verify skill is installed at ~/.claude/skills/, ~/.openclaw/skills/, or as a Claude Code plugin under ~/.claude/plugins/ |
ModuleNotFoundError: requests | requests not installed | pip install requests or uv pip install requests |
| 0 results returned | Query too specific or filters too narrow | Broaden query, remove filters, try search_relevance() instead of search_bulk() |
KeyError: 'data' | Endpoint returned error object | Check r.get("message") for API error details |
tldr field is empty | Not all papers have TLDR; bulk search never returns it | Fall back to abstract field |
"""Semantic Scholar API helper for the semanticscholar-skill.
Public surface (organized by phase of the skill's 4-phase workflow):
Plan / construct queries
build_bool_query(phrases, required, excluded, or_terms) -> str
deduplicate(papers) -> list
Execute searches
search_relevance(query, **filters) broad, ranked by relevance
search_bulk(query, sort=..., **filters) boolean syntax, up to 10M
search_snippets(query, **filters) full-text passage match
match_title(title) exact title lookup
paper_autocomplete(query) query-completion suggestions
Direct lookup
get_paper(paper_id) single paper, full fields
batch_papers(ids, fields) up to 500 papers in one POST
get_citations(paper_id, max_results) who cites this work
get_references(paper_id, max_results) what this work cites
get_paper_authors(paper_id, max_results)
Recommendations
find_similar(paper_id, limit, pool) single seed
recommend(positive_ids, negative_ids, ...) multi-seed
Authors
search_authors(query, max_results)
get_author(author_id)
get_author_papers(author_id, max_results)
batch_authors(ids, fields)
Present / export
format_table(papers) summary markdown table
format_details(papers) per-paper details with TLDR
format_citations(citations) citation list with intent labels
format_results(papers, query_desc) table + details combined
format_authors(authors) author table
export_bibtex(papers) | export_markdown(...) | export_json(...)
Trust + safety contract:
- Auth comes from the S2_API_KEY env var; never accepted via function args.
- All endpoints are read-only.
- Rate limiting (1.1s gap) and exponential backoff are enforced inside
_request and shared across the process via a module-level lock.
Filter kwargs are snake_case here and translated to the S2 camelCase params
inside _add_filters (year, publication_date -> publicationDateOrYear,
fields_of_study -> fieldsOfStudy, min_citations -> minCitationCount,
pub_types -> publicationTypes, open_access -> openAccessPdf, venue).
"""
import time, os, json, requests, sys
GRAPH = "https://api.semanticscholar.org/graph/v1"
RECS = "https://api.semanticscholar.org/recommendations/v1"
DATASETS = "https://api.semanticscholar.org/datasets/v1"
_API_KEY = os.environ.get("S2_API_KEY", "").strip()
HAS_KEY = bool(_API_KEY)
HEADERS = {"x-api-key": _API_KEY} if HAS_KEY else {}
# Per S2 docs: anonymous calls share a 1000 req/s pool globally and can be
# further throttled during heavy use; API keys get an introductory 1 req/s
# dedicated quota across all endpoints. 1.1s under a key, 5s otherwise — the
# anon pool can vanish under load, so we stay conservative without a key.
_last_request_time = 0
_MIN_GAP = 1.1 if HAS_KEY else 5.0
def _request(method, url, params=None, json_data=None, max_retries=5):
"""Send one request with rate limiting and exponential backoff.
Enforces a 1.1s minimum gap between requests, retries on 429/504 with
2s -> 60s exponential backoff (max 5 retries), and re-raises on other
4xx/5xx after surfacing the response body to stderr for debugging.
"""
global _last_request_time
elapsed = time.time() - _last_request_time
if elapsed < _MIN_GAP:
time.sleep(_MIN_GAP - elapsed)
for attempt in range(max_retries + 1):
_last_request_time = time.time()
try:
if method == "GET":
r = requests.get(url, params=params, headers=HEADERS, timeout=30)
else:
r = requests.post(url, params=params, json=json_data, headers=HEADERS, timeout=30)
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < max_retries:
wait = min(2 ** (attempt + 1), 60)
print(f" [conn-error] {type(e).__name__}, retry {attempt+1}/{max_retries} in {wait}s", file=sys.stderr)
time.sleep(wait)
continue
raise
if r.status_code == 403 and HAS_KEY:
# Invalid/expired key — drop it and retry unauthenticated this attempt.
import s2 as _self
print(" [auth] S2_API_KEY rejected (403); switching to unauthenticated mode", file=sys.stderr)
_self.HAS_KEY = False
_self.HEADERS = {}
_self._MIN_GAP = 5.0
continue
if r.status_code in (429, 504):
if attempt < max_retries:
wait = min(2 ** (attempt + 1), 60)
print(f" [rate-limit] {r.status_code}, retry {attempt+1}/{max_retries} in {wait}s", file=sys.stderr)
time.sleep(wait)
continue
r.raise_for_status()
elif r.status_code >= 400:
# Surface API error body before raising — S2 returns {"message": "..."} or {"error": "..."}
print(f" [http-{r.status_code}] {r.text[:300]}", file=sys.stderr)
r.raise_for_status()
return r.json()
def s2_get(url, params=None):
"""Low-level GET; use the higher-level helpers below in normal use."""
return _request("GET", url, params=params)
def s2_post(url, params=None, json_data=None):
"""Low-level POST; use the higher-level helpers below in normal use."""
return _request("POST", url, params=params, json_data=json_data)
# --- Pagination ---
def paginate(url, params=None, max_results=1000):
"""Offset-based pagination via the `next` cursor (search/citations/references/authors)."""
params = dict(params or {})
params.setdefault("limit", 100)
params["offset"] = 0
results = []
while len(results) < max_results:
r = s2_get(url, params)
results.extend(r.get("data", []))
if "next" not in r or len(results) >= max_results:
break
params["offset"] = r["next"]
return results[:max_results]
def paginate_bulk(url, params=None, max_results=10000):
"""Token-based pagination for /paper/search/bulk (up to ~10M results)."""
params = dict(params or {})
token = None
results = []
while len(results) < max_results:
if token:
params["token"] = token
r = s2_get(url, params)
results.extend(r.get("data", []))
token = r.get("token")
if not token or len(results) >= max_results:
break
return results[:max_results]
# --- Batch ---
def batch_papers(ids, fields="title,year,citationCount"):
"""POST up to 500 paper IDs in one request. Returns a list aligned to `ids` order."""
return s2_post(f"{GRAPH}/paper/batch", params={"fields": fields}, json_data={"ids": ids[:500]})
def batch_authors(ids, fields="name,hIndex,paperCount"):
"""POST up to 1000 author IDs in one request."""
return s2_post(f"{GRAPH}/author/batch", params={"fields": fields}, json_data={"ids": ids[:1000]})
# --- High-level search functions ---
_DEFAULT_FIELDS = "title,year,citationCount,authors,venue,externalIds,tldr"
_BULK_FIELDS = "title,year,citationCount,authors,venue,externalIds" # bulk search doesn't support tldr
def _add_filters(params, year=None, venue=None, fields_of_study=None,
min_citations=None, pub_types=None, open_access=False,
publication_date=None):
"""Translate snake_case skill kwargs into the S2 camelCase query params."""
if year: params["year"] = year
if publication_date: params["publicationDateOrYear"] = publication_date
if venue: params["venue"] = venue
if fields_of_study: params["fieldsOfStudy"] = fields_of_study
if min_citations: params["minCitationCount"] = str(min_citations)
if pub_types: params["publicationTypes"] = pub_types
if open_access: params["openAccessPdf"] = ""
return params
def search_relevance(query, fields=_DEFAULT_FIELDS, max_results=20, **filters):
"""Relevance-ranked paper search. Up to 1,000 results. Use for broad topic exploration."""
params = _add_filters({"query": query, "fields": fields, "limit": min(max_results, 100)}, **filters)
if max_results <= 100:
r = s2_get(f"{GRAPH}/paper/search", params)
return r.get("data", [])[:max_results]
return paginate(f"{GRAPH}/paper/search", params, max_results)
def search_bulk(query, fields=_BULK_FIELDS, max_results=100, sort="citationCount:desc", **filters):
"""Bulk search with boolean operators and `sort`. Up to ~10M results.
NOTE: tldr is not available on this endpoint — use search_relevance if TLDR matters.
Build precise queries with build_bool_query() to avoid noisy matches.
"""
params = _add_filters({"query": query, "fields": fields, "sort": sort}, **filters)
return paginate_bulk(f"{GRAPH}/paper/search/bulk", params, max_results)
def search_snippets(query, fields="snippet.text,snippet.snippetKind,snippet.section",
max_results=10, paper_ids=None, authors=None, inserted_before=None, **filters):
"""Full-text passage search — finds papers containing specific sentences/methods, not just titles.
Snippet-only filters (in addition to the shared `_add_filters` set):
paper_ids List of paperIds to scope the search to (sent as `paperIds`)
authors List of authorIds to scope the search to (sent as `authors`)
inserted_before YYYY-MM-DD; restrict to snippets ingested before this date
"""
params = _add_filters({"query": query, "fields": fields, "limit": min(max_results, 100)}, **filters)
if paper_ids: params["paperIds"] = ",".join(paper_ids) if isinstance(paper_ids, list) else paper_ids
if authors: params["authors"] = ",".join(authors) if isinstance(authors, list) else authors
if inserted_before: params["insertedBefore"] = inserted_before
return s2_get(f"{GRAPH}/snippet/search", params).get("data", [])[:max_results]
def paper_autocomplete(query):
"""Suggest paper completions for an in-progress query string. Returns a list of {id, title, authorsYear}.
NOTE: this endpoint is on the public tier and rejects API-key auth with 403, so
it's called without HEADERS. Still subject to the shared rate limiter.
"""
global _last_request_time
elapsed = time.time() - _last_request_time
if elapsed < _MIN_GAP:
time.sleep(_MIN_GAP - elapsed)
_last_request_time = time.time()
r = requests.get(f"{GRAPH}/paper/autocomplete", params={"query": query}, timeout=30)
r.raise_for_status()
return r.json().get("matches", [])
def get_paper(paper_id, fields=_DEFAULT_FIELDS + ",abstract,references,openAccessPdf"):
"""Fetch one paper by ID. Accepts DOI:, ARXIV:, PMID:, PMCID:, CorpusId:, ACL:, MAG:, URL: prefixes."""
return s2_get(f"{GRAPH}/paper/{paper_id}", {"fields": fields})
def get_citations(paper_id, fields="title,year,citationCount,authors,venue,contextsWithIntent",
max_results=100, publication_date=None):
"""List citations of a paper. Each item is {citingPaper, contextsWithIntent: [{context, intents}]}.
Default fields include contextsWithIntent so format_citations() can render
methodology/background/result intent labels per citation. Pass without it
to reduce payload if intents aren't needed.
`publication_date` (YYYY-MM-DD or year range) filters by the citing paper's date.
"""
params = {"fields": fields, "limit": min(max_results, 1000)}
if publication_date: params["publicationDateOrYear"] = publication_date
return paginate(f"{GRAPH}/paper/{paper_id}/citations", params, max_results)
def get_references(paper_id, fields="title,year,citationCount,authors,venue", max_results=100):
"""List references of a paper. Each item is {citedPaper, contexts, intents}."""
return paginate(f"{GRAPH}/paper/{paper_id}/references",
{"fields": fields, "limit": min(max_results, 1000)}, max_results)
def find_similar(paper_id, fields="title,year,citationCount,authors,venue", limit=10, pool="recent"):
"""Single-seed recommendations. `pool` is "recent" or "all-cs" (CS-only legacy pool)."""
return s2_get(f"{RECS}/papers/forpaper/{paper_id}",
{"fields": fields, "limit": limit, "from": pool}).get("recommendedPapers", [])
def recommend(positive_ids, negative_ids=None, fields="title,year,citationCount,authors,venue", limit=10):
"""Multi-seed recommendations: papers similar to positives but unlike negatives."""
body = {"positivePaperIds": positive_ids}
if negative_ids:
body["negativePaperIds"] = negative_ids
return s2_post(f"{RECS}/papers/", params={"fields": fields, "limit": limit},
json_data=body).get("recommendedPapers", [])
# --- Author functions ---
_DEFAULT_AUTHOR_FIELDS = "name,affiliations,paperCount,citationCount,hIndex"
def search_authors(query, fields=_DEFAULT_AUTHOR_FIELDS, max_results=20):
"""Search authors by name. Up to 1,000 results. Pick by affiliation/h-index when names collide."""
params = {"query": query, "fields": fields, "limit": min(max_results, 1000)}
if max_results <= 1000:
r = s2_get(f"{GRAPH}/author/search", params)
return r.get("data", [])[:max_results]
return paginate(f"{GRAPH}/author/search", params, max_results)
def get_author(author_id, fields=_DEFAULT_AUTHOR_FIELDS):
"""Fetch one author by ID."""
return s2_get(f"{GRAPH}/author/{author_id}", {"fields": fields})
def get_author_papers(author_id, fields=_DEFAULT_FIELDS, max_results=100, publication_date=None):
"""List an author's papers, paginated. `publication_date` (YYYY-MM-DD or year range) filters by paper date."""
params = {"fields": fields, "limit": min(max_results, 1000)}
if publication_date: params["publicationDateOrYear"] = publication_date
return paginate(f"{GRAPH}/author/{author_id}/papers", params, max_results)
def get_paper_authors(paper_id, fields=_DEFAULT_AUTHOR_FIELDS, max_results=100):
"""List the authors of a paper."""
return paginate(f"{GRAPH}/paper/{paper_id}/authors",
{"fields": fields, "limit": min(max_results, 1000)}, max_results)
def match_title(title, fields=_DEFAULT_FIELDS):
"""Closest title match — single best result. Use when you have a title string and need the paper."""
return s2_get(f"{GRAPH}/paper/search/match", {"query": title, "fields": fields})
# --- Utilities ---
def deduplicate(papers):
"""Drop duplicates by paperId, preserving first-seen order."""
seen = set()
out = []
for p in papers:
pid = p.get("paperId")
if pid and pid not in seen:
seen.add(pid)
out.append(p)
return out
def build_bool_query(phrases=None, required=None, excluded=None, or_terms=None,
fuzzy=None, proximity=None):
"""Compose a boolean query string for search_bulk.
phrases -> "exact phrase" (quoted)
required -> +term (must include)
excluded -> -term (must exclude)
or_terms -> (a | b | c) group
fuzzy -> list of (term, edit_distance): bugs~3 matches buggy/buns/busg
proximity -> list of (phrase, word_distance): ("blue lake", 3) matches
phrases with up to 3 words between "blue" and "lake"
"""
parts = []
for p in (phrases or []):
parts.append(f'"{p}"')
for r in (required or []):
parts.append(f"+{r}")
for e in (excluded or []):
parts.append(f"-{e}")
if or_terms:
parts.append("(" + " | ".join(or_terms) + ")")
for term, dist in (fuzzy or []):
parts.append(f"{term}~{dist}")
for phrase, dist in (proximity or []):
parts.append(f'"{phrase}"~{dist}')
return " ".join(parts)
# --- Datasets API ---
def list_releases():
"""Return a list of all available dataset release date strings (newest last)."""
return s2_get(f"{DATASETS}/release/")
def list_datasets(release_id="latest"):
"""Return dataset metadata for a given release. Use release_id='latest' for the newest."""
return s2_get(f"{DATASETS}/release/{release_id}")
def get_dataset_links(release_id, dataset_name):
"""Return pre-signed download URLs for a dataset file. Requires a valid API key."""
if not HAS_KEY:
raise RuntimeError("get_dataset_links requires S2_API_KEY — dataset downloads are not available unauthenticated")
return s2_get(f"{DATASETS}/release/{release_id}/dataset/{dataset_name}")
def get_dataset_diffs(start_release_id, end_release_id, dataset_name):
"""Return incremental diffs (update + delete file lists) between two releases. Requires API key."""
if not HAS_KEY:
raise RuntimeError("get_dataset_diffs requires S2_API_KEY")
return s2_get(f"{DATASETS}/diffs/{start_release_id}/to/{end_release_id}/{dataset_name}")
# --- Output formatting ---
def _doi(paper):
ext = paper.get("externalIds") or {}
return ext.get("DOI", "")
def _first_author(paper):
authors = paper.get("authors") or []
if not authors:
return ""
name = authors[0].get("name", "")
return f"{name} et al." if len(authors) > 1 else name
def format_table(papers, max_rows=30):
"""Markdown summary table: # | Title | Year | Cites | First Author | Venue."""
rows = ["| # | Title | Year | Cites | First Author | Venue |",
"|---|-------|------|-------|-------------|-------|"]
for i, p in enumerate(papers[:max_rows], 1):
t = (p.get("title") or "")[:80]
y = p.get("year") or ""
c = p.get("citationCount") or 0
a = _first_author(p)[:25]
v = (p.get("venue") or "")[:30]
rows.append(f"| {i} | {t} | {y} | {c} | {a} | {v} |")
return "\n".join(rows)
def format_details(papers, max_papers=10):
"""Per-paper detailed entries (title, authors, citations, DOI, TLDR/abstract)."""
lines = []
for i, p in enumerate(papers[:max_papers], 1):
title = p.get("title") or "Untitled"
year = p.get("year") or "?"
cites = p.get("citationCount") or 0
doi = _doi(p)
authors = ", ".join(a.get("name", "") for a in (p.get("authors") or [])[:5])
if len(p.get("authors") or []) > 5:
authors += " et al."
tldr = (p.get("tldr") or {}).get("text", "")
abstract = (p.get("abstract") or "")[:300]
summary = tldr or (abstract + "..." if len(p.get("abstract") or "") > 300 else abstract)
lines.append(f"### {i}. {title} ({year})")
lines.append(f"**Authors:** {authors}")
lines.append(f"**Citations:** {cites} | **DOI:** {doi}" if doi else f"**Citations:** {cites}")
if summary:
lines.append(f"**Summary:** {summary}")
lines.append("")
return "\n".join(lines)
def format_citations(citations, max_items=10):
"""Render citation envelopes with intent labels and a representative context snippet.
Input shape (from get_citations): [{citingPaper: {...}, contextsWithIntent: [{context, intents}]}].
Use this instead of format_details when you want WHY a paper was cited
(methodology / background / result), not just which papers cited it.
"""
lines = []
for i, c in enumerate(citations[:max_items], 1):
p = c.get("citingPaper") or {}
title = p.get("title") or "Untitled"
year = p.get("year") or "?"
cites = p.get("citationCount") or 0
authors = _first_author(p)
cwi = c.get("contextsWithIntent") or []
intents = sorted({i for entry in cwi for i in (entry.get("intents") or [])})
snippet = (cwi[0].get("context") if cwi else "")[:240]
lines.append(f"### {i}. {title} ({year})")
lines.append(f"**Cited by:** {authors} | **Citations:** {cites}")
if intents:
lines.append(f"**Intents:** {', '.join(intents)}")
if snippet:
lines.append(f"**Context:** \"{snippet}{'...' if len(snippet) == 240 else ''}\"")
lines.append("")
return "\n".join(lines)
def format_results(papers, query_desc=""):
"""Combined header + summary table + top-10 details."""
n = len(papers)
header = f"## Search Results: {query_desc}\n\n**{n} papers found.**\n" if query_desc else f"**{n} papers found.**\n"
table = format_table(papers)
details = format_details(papers[:10])
return f"{header}\n{table}\n\n---\n\n{details}"
def format_authors(authors, max_rows=20):
"""Markdown table of authors with affiliations, paper count, citations, h-index."""
rows = ["| # | Name | Affiliations | Papers | Citations | h-index |",
"|---|------|-------------|--------|-----------|---------|"]
for i, a in enumerate(authors[:max_rows], 1):
name = a.get("name", "")
affs = ", ".join(a.get("affiliations") or [])[:40]
pc = a.get("paperCount") or 0
cc = a.get("citationCount") or 0
h = a.get("hIndex") or 0
rows.append(f"| {i} | {name} | {affs} | {pc} | {cc} | {h} |")
return "\n".join(rows)
def export_bibtex(papers):
"""Concat the `citationStyles.bibtex` field across papers. Requires fields=...,citationStyles."""
entries = []
for p in papers:
bib = (p.get("citationStyles") or {}).get("bibtex")
if bib:
entries.append(bib)
return "\n\n".join(entries)
def export_markdown(papers, query_desc="", path="/tmp/s2_results.md"):
"""Write format_results() output to a file. Returns the path."""
content = format_results(papers, query_desc)
with open(path, "w") as f:
f.write(content)
print(f"Saved {len(papers)} papers to {path}")
return path
def export_json(papers, path="/tmp/s2_results.json"):
"""Dump the raw paper list as JSON. Returns the path."""
with open(path, "w") as f:
json.dump(papers, f, indent=2, ensure_ascii=False)
print(f"Saved {len(papers)} papers to {path}")
return path
Related skills
How it compares
Pick semanticscholar-skill for structured academic API queries; use general web search skills for blog posts and documentation instead of peer-reviewed papers.
FAQ
Why one Python script per search batch?
Avoids sequential Bash calls; s2.py handles rate limiting internally.
Bulk or relevance search default?
Prefer search_bulk unless TLDR or inline author details are required.
Is an API key required?
No, but S2_API_KEY raises rate limits for heavier usage.
Is Semanticscholar Skill safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.