mirror of
https://github.com/hanzoskill/literature-review.git
synced 2026-10-30 21:03:22 +00:00
369 lines
15 KiB
Python
369 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
import sys
|
|
import json
|
|
import requests
|
|
import argparse
|
|
import os
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
S2_BASE_URL = "https://api.semanticscholar.org/graph/v1"
|
|
OA_BASE_URL = "https://api.openalex.org"
|
|
CR_BASE_URL = "https://api.crossref.org/works"
|
|
PM_BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
|
|
|
|
# Timeout for all requests (seconds)
|
|
REQUEST_TIMEOUT = 30
|
|
|
|
# Env vars - NO hardcoded emails
|
|
S2_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY")
|
|
OA_API_KEY = os.getenv("OPENALEX_API_KEY")
|
|
USER_EMAIL = os.getenv("USER_EMAIL", os.getenv("BOT_EMAIL", "[email protected]"))
|
|
|
|
def get_s2_headers():
|
|
headers = {}
|
|
if S2_API_KEY:
|
|
headers["x-api-key"] = S2_API_KEY
|
|
return headers
|
|
|
|
def get_oa_params(params):
|
|
if OA_API_KEY:
|
|
params["api_key"] = OA_API_KEY
|
|
return params
|
|
|
|
def get_headers():
|
|
return {"User-Agent": f"HanzoBot/1.0 (mailto:{USER_EMAIL})"}
|
|
|
|
def safe_get_year(item, *keys):
|
|
"""Safely extract year from nested dict structures, handling None/missing fields."""
|
|
for key in keys:
|
|
try:
|
|
val = item.get(key)
|
|
if val is None:
|
|
continue
|
|
if isinstance(val, int):
|
|
return val
|
|
if isinstance(val, str):
|
|
# Try to extract year from string like "2023" or "2023-01-15"
|
|
year_str = val.split("-")[0].split(" ")[0]
|
|
if year_str.isdigit() and len(year_str) == 4:
|
|
return int(year_str)
|
|
if isinstance(val, dict):
|
|
# Handle Crossref date-parts format
|
|
date_parts = val.get("date-parts")
|
|
if date_parts and isinstance(date_parts, list) and len(date_parts) > 0:
|
|
first_part = date_parts[0]
|
|
if isinstance(first_part, list) and len(first_part) > 0 and first_part[0]:
|
|
return first_part[0]
|
|
except (TypeError, KeyError, IndexError, AttributeError):
|
|
continue
|
|
return None
|
|
|
|
def search_s2(query, limit=10):
|
|
fields = "title,year,abstract,authors,citationCount,venue,externalIds"
|
|
url = f"{S2_BASE_URL}/paper/search"
|
|
params = {"query": query, "limit": limit, "fields": fields}
|
|
try:
|
|
response = requests.get(url, params=params, headers=get_s2_headers(), timeout=REQUEST_TIMEOUT)
|
|
if response.status_code != 200:
|
|
return {"error": f"HTTP {response.status_code}: {response.text[:200]}", "data": []}
|
|
data = response.json()
|
|
# Normalize output to include doi field for deduplication
|
|
results = []
|
|
for paper in data.get("data", []):
|
|
ext_ids = paper.get("externalIds") or {}
|
|
results.append({
|
|
"id": paper.get("paperId"),
|
|
"doi": ext_ids.get("DOI"),
|
|
"title": paper.get("title"),
|
|
"year": paper.get("year"),
|
|
"abstract": paper.get("abstract"),
|
|
"authors": [a.get("name") for a in (paper.get("authors") or [])],
|
|
"citationCount": paper.get("citationCount"),
|
|
"venue": paper.get("venue"),
|
|
"source": "semantic_scholar"
|
|
})
|
|
return {"total": data.get("total"), "data": results}
|
|
except requests.exceptions.Timeout:
|
|
return {"error": "Request timed out", "data": []}
|
|
except Exception as e:
|
|
return {"error": str(e), "data": []}
|
|
|
|
def search_oa(query, limit=10):
|
|
url = f"{OA_BASE_URL}/works"
|
|
params = {
|
|
"search": query,
|
|
"per_page": limit,
|
|
"select": "id,doi,title,display_name,publication_year,cited_by_count,authorships,abstract_inverted_index,primary_location"
|
|
}
|
|
try:
|
|
response = requests.get(url, params=get_oa_params(params), headers=get_headers(), timeout=REQUEST_TIMEOUT)
|
|
if response.status_code != 200:
|
|
return {"error": f"HTTP {response.status_code}: {response.text[:200]}", "data": []}
|
|
data = response.json()
|
|
results = []
|
|
for work in data.get("results", []):
|
|
abstract = ""
|
|
index = work.get("abstract_inverted_index")
|
|
if index:
|
|
words = {}
|
|
for word, positions in index.items():
|
|
for pos in positions: words[pos] = word
|
|
abstract = " ".join([words[p] for p in sorted(words.keys())])
|
|
|
|
# Safe venue extraction
|
|
venue = None
|
|
primary_loc = work.get("primary_location")
|
|
if primary_loc and isinstance(primary_loc, dict):
|
|
source = primary_loc.get("source")
|
|
if source and isinstance(source, dict):
|
|
venue = source.get("display_name")
|
|
|
|
results.append({
|
|
"id": work.get("id"),
|
|
"doi": work.get("doi"),
|
|
"title": work.get("title") or work.get("display_name"),
|
|
"year": work.get("publication_year"),
|
|
"citationCount": work.get("cited_by_count"),
|
|
"authors": [a.get("author", {}).get("display_name") for a in work.get("authorships", [])],
|
|
"abstract": abstract,
|
|
"venue": venue,
|
|
"source": "openalex"
|
|
})
|
|
return {"total": data.get("meta", {}).get("count"), "data": results}
|
|
except requests.exceptions.Timeout:
|
|
return {"error": "Request timed out", "data": []}
|
|
except Exception as e:
|
|
return {"error": str(e), "data": []}
|
|
|
|
def search_cr(query, limit=10):
|
|
params = {"query": query, "rows": limit}
|
|
try:
|
|
response = requests.get(CR_BASE_URL, params=params, headers=get_headers(), timeout=REQUEST_TIMEOUT)
|
|
if response.status_code != 200:
|
|
return {"error": f"HTTP {response.status_code}: {response.text[:200]}", "data": []}
|
|
data = response.json()
|
|
items = data.get("message", {}).get("items", [])
|
|
results = []
|
|
for item in items:
|
|
# Safe year extraction using helper
|
|
year = safe_get_year(item, "published-print", "published-online", "created")
|
|
|
|
results.append({
|
|
"id": item.get("DOI"),
|
|
"doi": item.get("DOI"),
|
|
"title": " ".join(item.get("title", [])) if item.get("title") else None,
|
|
"year": year,
|
|
"authors": [f"{a.get('given', '')} {a.get('family', '')}".strip() for a in item.get("author", [])],
|
|
"venue": " ".join(item.get("container-title", [])) if item.get("container-title") else None,
|
|
"source": "crossref"
|
|
})
|
|
return {"total": data.get("message", {}).get("total-results"), "data": results}
|
|
except requests.exceptions.Timeout:
|
|
return {"error": "Request timed out", "data": []}
|
|
except Exception as e:
|
|
return {"error": str(e), "data": []}
|
|
|
|
def search_pm(query, limit=10):
|
|
"""Search PubMed using esearch + efetch for full abstracts and DOIs."""
|
|
try:
|
|
# Step 1: esearch to get PMIDs
|
|
search_url = f"{PM_BASE_URL}/esearch.fcgi"
|
|
search_params = {"db": "pubmed", "term": query, "retmax": limit, "retmode": "json"}
|
|
search_res = requests.get(search_url, params=search_params, timeout=REQUEST_TIMEOUT)
|
|
if search_res.status_code != 200:
|
|
return {"error": f"esearch HTTP {search_res.status_code}", "data": []}
|
|
ids = search_res.json().get("esearchresult", {}).get("idlist", [])
|
|
if not ids:
|
|
return {"total": 0, "data": []}
|
|
|
|
# Step 2: efetch to get full records with abstracts
|
|
fetch_url = f"{PM_BASE_URL}/efetch.fcgi"
|
|
fetch_params = {
|
|
"db": "pubmed",
|
|
"id": ",".join(ids),
|
|
"retmode": "xml",
|
|
"rettype": "abstract"
|
|
}
|
|
fetch_res = requests.get(fetch_url, params=fetch_params, timeout=REQUEST_TIMEOUT)
|
|
if fetch_res.status_code != 200:
|
|
return {"error": f"efetch HTTP {fetch_res.status_code}", "data": []}
|
|
|
|
# Parse XML
|
|
results = []
|
|
root = ET.fromstring(fetch_res.content)
|
|
|
|
for article in root.findall(".//PubmedArticle"):
|
|
pmid = None
|
|
pmid_elem = article.find(".//PMID")
|
|
if pmid_elem is not None:
|
|
pmid = pmid_elem.text
|
|
|
|
# Title
|
|
title = None
|
|
title_elem = article.find(".//ArticleTitle")
|
|
if title_elem is not None:
|
|
title = "".join(title_elem.itertext())
|
|
|
|
# Abstract - combine all AbstractText elements
|
|
abstract_parts = []
|
|
for abs_elem in article.findall(".//AbstractText"):
|
|
label = abs_elem.get("Label", "")
|
|
text = "".join(abs_elem.itertext())
|
|
if label:
|
|
abstract_parts.append(f"{label}: {text}")
|
|
else:
|
|
abstract_parts.append(text)
|
|
abstract = " ".join(abstract_parts) if abstract_parts else None
|
|
|
|
# Year
|
|
year = None
|
|
pub_date = article.find(".//PubDate/Year")
|
|
if pub_date is not None and pub_date.text:
|
|
try:
|
|
year = int(pub_date.text)
|
|
except ValueError:
|
|
pass
|
|
if not year:
|
|
# Try MedlineDate
|
|
medline_date = article.find(".//PubDate/MedlineDate")
|
|
if medline_date is not None and medline_date.text:
|
|
parts = medline_date.text.split()
|
|
if parts and parts[0].isdigit():
|
|
year = int(parts[0])
|
|
|
|
# Authors
|
|
authors = []
|
|
for author in article.findall(".//Author"):
|
|
last = author.find("LastName")
|
|
first = author.find("ForeName")
|
|
if last is not None:
|
|
name = last.text or ""
|
|
if first is not None and first.text:
|
|
name = f"{first.text} {name}"
|
|
authors.append(name.strip())
|
|
|
|
# Journal
|
|
venue = None
|
|
journal = article.find(".//Journal/Title")
|
|
if journal is not None:
|
|
venue = journal.text
|
|
|
|
# DOI
|
|
doi = None
|
|
for aid in article.findall(".//ArticleId"):
|
|
if aid.get("IdType") == "doi":
|
|
doi = aid.text
|
|
break
|
|
# Also check ELocationID
|
|
if not doi:
|
|
for eloc in article.findall(".//ELocationID"):
|
|
if eloc.get("EIdType") == "doi":
|
|
doi = eloc.text
|
|
break
|
|
|
|
results.append({
|
|
"id": pmid,
|
|
"pmid": pmid,
|
|
"doi": doi,
|
|
"title": title,
|
|
"year": year,
|
|
"authors": authors,
|
|
"abstract": abstract,
|
|
"venue": venue,
|
|
"source": "pubmed"
|
|
})
|
|
|
|
return {"total": len(results), "data": results}
|
|
except requests.exceptions.Timeout:
|
|
return {"error": "Request timed out", "data": []}
|
|
except ET.ParseError as e:
|
|
return {"error": f"XML parse error: {e}", "data": []}
|
|
except Exception as e:
|
|
return {"error": str(e), "data": []}
|
|
|
|
def deduplicate_by_doi(all_results):
|
|
"""Deduplicate results by DOI, keeping the first occurrence (with most metadata)."""
|
|
seen_dois = set()
|
|
unique = []
|
|
no_doi = []
|
|
|
|
for result in all_results:
|
|
doi = result.get("doi")
|
|
if doi:
|
|
# Normalize DOI (lowercase, strip https://doi.org/ prefix)
|
|
doi_normalized = doi.lower().replace("https://doi.org/", "").replace("http://doi.org/", "")
|
|
if doi_normalized not in seen_dois:
|
|
seen_dois.add(doi_normalized)
|
|
unique.append(result)
|
|
else:
|
|
# Keep results without DOI (can't dedupe)
|
|
no_doi.append(result)
|
|
|
|
return unique + no_doi
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Multi-Source Academic Search Tool")
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
# Search
|
|
search_parser = subparsers.add_parser("search", help="Search for papers")
|
|
search_parser.add_argument("query", help="Search query")
|
|
search_parser.add_argument("--limit", type=int, default=10, help="Max results per source")
|
|
search_parser.add_argument("--source", choices=["s2", "oa", "cr", "pm", "both", "all"], default="oa",
|
|
help="Source: s2 (Semantic Scholar), oa (OpenAlex), cr (Crossref), pm (PubMed)")
|
|
search_parser.add_argument("--dedupe", action="store_true", help="Deduplicate results by DOI (auto-enabled for multi-source)")
|
|
|
|
# Details (S2)
|
|
details_parser = subparsers.add_parser("details", help="Get paper details (S2)")
|
|
details_parser.add_argument("id", help="Paper ID (DOI, S2ID)")
|
|
|
|
args = parser.parse_known_args()[0]
|
|
|
|
if args.command == "search":
|
|
output = {}
|
|
if args.source in ["s2", "both", "all"]: output["semantic_scholar"] = search_s2(args.query, args.limit)
|
|
if args.source in ["oa", "both", "all"]: output["openalex"] = search_oa(args.query, args.limit)
|
|
if args.source in ["cr", "all"]: output["crossref"] = search_cr(args.query, args.limit)
|
|
if args.source in ["pm", "all"]: output["pubmed"] = search_pm(args.query, args.limit)
|
|
|
|
# Flatten if only one source
|
|
if len(output) == 1:
|
|
print(json.dumps(list(output.values())[0], indent=2, ensure_ascii=False))
|
|
else:
|
|
# Multi-source: deduplicate by default (or if --dedupe)
|
|
if args.dedupe or args.source in ["both", "all"]:
|
|
all_data = []
|
|
for src_name, src_result in output.items():
|
|
all_data.extend(src_result.get("data", []))
|
|
deduped = deduplicate_by_doi(all_data)
|
|
combined = {
|
|
"total_before_dedupe": sum(len(r.get("data", [])) for r in output.values()),
|
|
"total_after_dedupe": len(deduped),
|
|
"data": deduped
|
|
}
|
|
print(json.dumps(combined, indent=2, ensure_ascii=False))
|
|
else:
|
|
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
|
|
elif args.command == "details":
|
|
try:
|
|
url = f"{S2_BASE_URL}/paper/{args.id}"
|
|
params = {"fields": "title,year,abstract,authors,citationCount,venue,externalIds,tldr,referenceCount"}
|
|
response = requests.get(url, params=params, headers=get_s2_headers(), timeout=REQUEST_TIMEOUT)
|
|
if response.status_code != 200:
|
|
print(json.dumps({"error": f"HTTP {response.status_code}: {response.text[:500]}"}, indent=2))
|
|
sys.exit(1)
|
|
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
|
except requests.exceptions.Timeout:
|
|
print(json.dumps({"error": "Request timed out"}))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e)}))
|
|
sys.exit(1)
|
|
else:
|
|
parser.print_help()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|