
Nature Academic Search
- 2 installs
- 33.4k repo stars
- Updated August 4, 2026
- yuan1z0825/nature-skill
This is a copy of nature-academic-search by yuan1z0825 - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks during AI-assisted development.
About
nature-academic-search is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- nature-academic-search
- AI & Agent Building
- AI-coding skill
Nature Academic Search by the numbers
- 2 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yuan1z0825/nature-skill --skill nature-academic-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 33.4k |
| Last updated | August 4, 2026 |
| Repository | yuan1z0825/nature-skill ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Academic Search — Router
This skill is split into two layers:
- A static layer under
static/that holds versioned, reusable content fragments (the MCP tool inventory and shared modules, and source routing plus operational rules). - A dynamic layer (this file plus
manifest.yaml) that detects which workflow the user needs and loads that workflow, reaching for shared modules and scripts only when a step needs them.
Do not try to apply the search logic from memory or from this router. Always load fragments from disk as described below.
Routing protocol
Follow these five steps every time the skill is invoked.
1. Load the manifest and the core layer
Read manifest.yaml. It declares the workflow axis, the allowed values, and the file paths each value maps to.
Also read every file listed under always_load:
static/core/tools.md— the MCP tool inventory (core search, extended search, PubMed utilities) and the shared-module map.static/core/routing-and-ops.md— the T1→T2→T3 source routing quick guide, environment setup, error handling, and limitations.
2. Detect the workflow
Map the user's need to one or more workflow values:
multi-source-search— find literature across sources.citation-verification— verify citations extracted from a document.mesh-strategy— build a MeSH/PubMed search strategy.citation-file-mgmt— convert/manage.nbib/.ris/.bibfiles.reference-mgmt— BibTeX, related-article discovery, ID conversion.
A combined request (for example search then export) may need more than one. State the detected workflow(s) in one short line before proceeding.
3. Load the matching workflow fragment(s)
Read the file mapped for each detected workflow (under references/workflows/). Do not read every workflow. Each workflow file links to the shared modules it needs.
4. Run the workflow using the loaded material
Apply the loaded material in this order:
1. Core tools and routing (core/tools.md, core/routing-and-ops.md) — which MCP tool for which need, and the T1→T2→T3 fallback chain that is the standard execution order across all workflows. 2. The workflow fragment — its specific steps. 3. Shared modules and scripts on demand (dedup, citation parser, search strategy, RIS/BibTeX format, format converter).
Report specific tool failures and continue with remaining tools; broaden terms when there are no results; fall back to manual generation from MCP-fetched metadata if a script fails twice.
5. Reach for references only when needed
The files under references/ (and scripts/) are deep references, not defaults. Open them on demand per the references.on_demand table in the manifest — for example references/source-tiers.md for the full reliability classification, references/dedup-engine.md / references/citation-parser.md / references/search-strategy.md / references/ris-bibtex-format.md for the shared modules, and scripts/academic_search.py (no-MCP fallback discovery search) / scripts/format-converter.py / scripts/preflight.py for the tooling.
Why this split
- The static layer is versioned and reviewable; the workflow files and shared modules were already factored this way.
- The dynamic layer keeps each invocation cheap: only the workflow the user needs enters context, instead of all five plus every module.
- The router itself is short on purpose. Update fragments and references, not this file, when adding scope.
- This structure mirrors the other nature-* skills (
nature-writing,nature-polishing,nature-reader,nature-paper2ppt,nature-figure,nature-citation,nature-response,nature-data).
{
"mcpServers": {
"academic-search": {
"command": "uv",
"args": [
"run",
"--no-project",
"--directory",
"<MCP_SERVER_DIR>",
"--with",
"mcp>=1.0.0,<2.0.0",
"--with",
"requests>=2.28.0,<3.0.0",
"--with",
"toml>=0.10.2,<2.0.0",
"--with",
"lxml>=4.9.0,<6.0.0",
"--with",
"pybliometrics>=4.4.1,<5.0.0",
"python",
"academic_search_server.py"
]
}
}
}
{
"enabledMcpjsonServers": [
"academic-search"
]
}
[academic-search]
trigger = ["查文献", "搜论文", "检索", "导入EndNote", "导出RIS", "检查参考文献", "验证DOI", "验证引用", "相关文献", "找类似论文", "构建检索式", "MeSH", "去重", "search papers", "find articles", "academic search", "literature search", "verify references", "check DOI", "verify citations", "download citation", "export .nbib", "export .ris", "BibTeX", "DOI", "PMID"]
priority = 5
suppress_active_skills = ["lit-process"]
#!/usr/bin/env bash
# Academic Search Skill + MCP Server Installer for Claude Code
# Usage: bash install.sh [PUBMED_EMAIL]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDE_DIR="${HOME}/.claude"
MCP_TARGET="${CLAUDE_DIR}/mcp_servers/academic-search"
SKILL_TARGET="${CLAUDE_DIR}/skills/academic-search"
MCP_JSON="${CLAUDE_DIR}/.mcp.json"
PUBMED_EMAIL="${1:-user@example.com}"
echo "=== Academic Search Installer ==="
echo "Target: ${CLAUDE_DIR}"
echo "PubMed email: ${PUBMED_EMAIL}"
echo
# 1. Install Python dependencies
echo "[1/5] Installing Python dependencies..."
pip install --quiet -r "${SCRIPT_DIR}/mcp-server/requirements.txt" 2>/dev/null || {
echo " pip failed, trying pip3..."
pip3 install --quiet -r "${SCRIPT_DIR}/mcp-server/requirements.txt" 2>/dev/null || {
echo " WARNING: Could not install Python deps. Install manually:"
echo " pip install -r mcp-server/requirements.txt"
}
}
# 2. Copy MCP server
echo "[2/5] Copying MCP server..."
mkdir -p "${MCP_TARGET}"
cp -r "${SCRIPT_DIR}/mcp-server/"* "${MCP_TARGET}/"
# 3. Copy Skill
echo "[3/5] Copying Skill..."
mkdir -p "${SKILL_TARGET}"
cp "${SCRIPT_DIR}/README.md" "${SKILL_TARGET}/"
cp "${SCRIPT_DIR}/SKILL.md" "${SKILL_TARGET}/"
cp -r "${SCRIPT_DIR}/references" "${SKILL_TARGET}/"
cp -r "${SCRIPT_DIR}/scripts" "${SKILL_TARGET}/"
cp -r "${SCRIPT_DIR}/config" "${SKILL_TARGET}/"
# 4. Merge .mcp.json
echo "[4/5] Configuring .mcp.json..."
if [ -f "${MCP_JSON}" ]; then
# Check if academic-search already exists
if grep -q '"academic-search"' "${MCP_JSON}" 2>/dev/null; then
echo " academic-search already in .mcp.json, skipping merge."
else
# Inject into existing mcpServers object
python3 -c "
import json, sys
with open('${MCP_JSON}', 'r') as f:
cfg = json.load(f)
cfg.setdefault('mcpServers', {})['academic-search'] = {
'command': 'python3',
'args': ['${MCP_TARGET}/academic_search_server.py'],
'env': {'PUBMED_EMAIL': '${PUBMED_EMAIL}'}
}
with open('${MCP_JSON}', 'w') as f:
json.dump(cfg, f, indent=2)
f.write('\n')
print(' Merged academic-search into existing .mcp.json')
"
fi
else
cat > "${MCP_JSON}" <<MCPJSON
{
"mcpServers": {
"academic-search": {
"command": "python3",
"args": ["${MCP_TARGET}/academic_search_server.py"],
"env": {
"PUBMED_EMAIL": "${PUBMED_EMAIL}"
}
}
}
}
MCPJSON
echo " Created new .mcp.json"
fi
# 5. Enable in settings.json
echo "[5/5] Enabling in settings.json..."
SETTINGS_JSON="${CLAUDE_DIR}/settings.json"
if [ -f "${SETTINGS_JSON}" ]; then
python3 -c "
import json
with open('${SETTINGS_JSON}', 'r') as f:
cfg = json.load(f)
enabled = cfg.setdefault('enabledMcpjsonServers', [])
if 'academic-search' not in enabled:
enabled.append('academic-search')
with open('${SETTINGS_JSON}', 'w') as f:
json.dump(cfg, f, indent=2)
f.write('\n')
print(' Added academic-search to enabledMcpjsonServers')
else:
print(' academic-search already enabled')
"
else
echo ' WARNING: settings.json not found. Manually add "academic-search" to enabledMcpjsonServers.'
fi
echo
echo "=== Done ==="
echo
echo "Installed:"
echo " MCP server : ${MCP_TARGET}/"
echo " Skill : ${SKILL_TARGET}/"
echo
echo "Next steps:"
echo " 1. Restart Claude Code (or /clear)"
echo " 2. Set your PubMed email in config.toml or PUBMED_EMAIL env var"
echo " 3. (Optional) Add NCBI_API_KEY for higher rate limits"
echo " 4. Test: ask Claude 'search papers about CRISPR'"
echo
echo "Optional: copy triggers to your data/triggers.toml"
echo " See: config/triggers-academic-search.toml"
name: nature-academic-search
version: 2.0.0
description: >
Declarative manifest for the static/dynamic split. SKILL.md uses this to
decide which fragments to load for a literature-search request. The main axis
is the workflow: the user's need maps to one of five coordinated workflows,
each already a self-contained file.
# Note on axis paths: the five workflow files live in references/workflows/ and
# cross-reference the shared modules with ../ relative links. The workflow axis
# therefore points at them in place rather than moving them into
# static/fragments/, which would break those internal links. nature-academic-
# search does not use the prose-oriented _shared layer.
always_load:
- static/core/tools.md
- static/core/routing-and-ops.md
axes:
workflow:
detect: |
Map the user's need to one workflow. Multiple may apply for a combined
request (for example search then export); load each that applies.
multi-source-search — find literature across PubMed/CrossRef/arXiv and more
citation-verification — verify or check citations extracted from a document
mesh-strategy — build a MeSH/PubMed search strategy
citation-file-mgmt — convert/manage .nbib/.ris/.bib citation files
reference-mgmt — BibTeX, related-article discovery, ID conversion
values:
multi-source-search: references/workflows/wf1-multi-source-search.md
citation-verification: references/workflows/wf2-citation-verification.md
mesh-strategy: references/workflows/wf3-mesh-strategy.md
citation-file-mgmt: references/workflows/wf4-citation-file-mgmt.md
reference-mgmt: references/workflows/wf5-reference-mgmt.md
multi: true
references:
on_demand:
- condition: full source reliability tiers (T1/T2/T3) and fallback routing rules
path: references/source-tiers.md
- condition: deduplication across sources (used by WFs 1, 2, 5a)
path: references/dedup-engine.md
- condition: extracting citations from documents (WF 2)
path: references/citation-parser.md
- condition: query construction, source selection, and result ranking
path: references/search-strategy.md
- condition: RIS/BibTeX format specifications and field mappings
path: references/ris-bibtex-format.md
- condition: no-MCP fallback discovery search (OpenAlex, stdlib) when the MCP server is unavailable
path: scripts/academic_search.py
- condition: multi-source .nbib/.ris/.bib downloading and conversion
path: scripts/format-converter.py
- condition: pre-flight check that API endpoints are reachable before batch operations
path: scripts/preflight.py
"""Academic search MCP server.
Unified entry point exposing multi-source search and source-specific tools for
CrossRef, PubMed, arXiv, Scopus, and ScienceDirect.
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from mcp.server import FastMCP
from sources import (
ArxivSource,
CrossRefSource,
PubMedSource,
ScienceDirectSource,
ScopusSource,
)
from utils import AcademicSearchError, DataSourceError, setup_logging
mcp = FastMCP("academic-search")
logger = setup_logging()
# Singleton source instances (shared across tool calls)
_crossref = CrossRefSource()
_pubmed = PubMedSource()
_arxiv = ArxivSource()
_scopus = ScopusSource()
_sciencedirect = ScienceDirectSource()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _detect_id_type(id: str) -> str:
"""Auto-detect identifier type.
Returns one of: "doi", "pmid", "arxiv".
Raises ValueError when detection fails.
"""
id = id.strip()
if id.startswith("10.") and "/" in id:
return "doi"
if re.match(r"^\d{7,8}$", id):
return "pmid"
if re.match(r"^\d{4}\.\d{4,5}(v\d+)?$", id):
return "arxiv"
raise ValueError(f"Cannot detect ID type for: {id}")
def _resolve_id_type(id: str, id_type: str) -> str:
"""Resolve the effective ID type.
If id_type is "auto", delegate to _detect_id_type.
Otherwise normalise the explicit type string.
"""
if id_type == "auto":
return _detect_id_type(id)
normalised = id_type.lower().strip()
if normalised in ("doi", "pmid", "arxiv"):
return normalised
raise ValueError(f"Unsupported id_type: {id_type}")
def _json_ok(data: Any) -> str:
"""Serialize a successful result to JSON string."""
return json.dumps(data, ensure_ascii=False, indent=2)
def _json_error(message: str, source: str | None = None) -> str:
"""Serialize an error result to JSON string."""
payload: dict[str, Any] = {"error": message}
if source:
payload["source"] = source
return json.dumps(payload, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Async wrappers for synchronous sources
# ---------------------------------------------------------------------------
async def _search_crossref(query: str, rows: int, filter_type: str | None) -> dict:
return await asyncio.to_thread(_crossref.search, query, rows, filter_type)
async def _search_pubmed(query: str, rows: int) -> dict:
return await asyncio.to_thread(_pubmed.search, query, rows)
async def _search_arxiv(query: str, rows: int) -> dict:
return await asyncio.to_thread(_arxiv.search, query, rows)
async def _search_scopus(query: str, rows: int) -> dict:
return await asyncio.to_thread(_scopus.search, query, rows)
async def _search_sciencedirect(query: str, rows: int) -> dict:
return await asyncio.to_thread(_sciencedirect.search, query, rows)
async def _search_all(
query: str,
sources: list[str],
rows: int,
filter_type: str | None,
) -> dict:
"""Dispatch concurrent searches and merge results."""
tasks: list[asyncio.Task] = []
source_order: list[str] = []
if "crossref" in sources:
tasks.append(asyncio.create_task(_search_crossref(query, rows, filter_type)))
source_order.append("crossref")
if "pubmed" in sources:
tasks.append(asyncio.create_task(_search_pubmed(query, rows)))
source_order.append("pubmed")
if "arxiv" in sources:
tasks.append(asyncio.create_task(_search_arxiv(query, rows)))
source_order.append("arxiv")
if "scopus" in sources:
tasks.append(asyncio.create_task(_search_scopus(query, rows)))
source_order.append("scopus")
if "sciencedirect" in sources:
tasks.append(asyncio.create_task(_search_sciencedirect(query, rows)))
source_order.append("sciencedirect")
if not tasks:
return {"total": 0, "results": [], "errors": []}
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
merged_results: list[dict] = []
errors: list[dict] = []
total = 0
for src, outcome in zip(source_order, outcomes):
if isinstance(outcome, BaseException):
logger.error("Source %s failed: %s", src, outcome)
errors.append({"source": src, "error": str(outcome)})
continue
total += outcome.get("total", 0)
merged_results.extend(outcome.get("results", []))
return {
"total": total,
"sources_queried": source_order,
"result_count": len(merged_results),
"results": merged_results,
"errors": errors if errors else None,
}
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@mcp.tool()
async def search_papers(
query: str,
sources: list[str] | None = None,
rows: int = 5,
type: str | None = None,
) -> str:
"""Search academic papers across multiple sources.
Args:
query: Search keywords or query string.
sources: List of source names to query. Defaults to CrossRef, PubMed,
and arXiv. Add "scopus" and/or "sciencedirect" explicitly to use
Elsevier-backed providers; they require local pybliometrics config
and may consume Elsevier API quota.
rows: Number of results per source (max 50), not total result count.
type: Optional CrossRef-only work type filter (e.g. "journal-article").
Returns:
JSON string with total count, merged results, and any per-source errors.
"""
if not query or not query.strip():
return _json_error("Empty search query")
if sources is None:
sources = ["crossref", "pubmed", "arxiv"]
# Validate source names
valid_sources = {"crossref", "pubmed", "arxiv", "scopus", "sciencedirect"}
invalid = [s for s in sources if s not in valid_sources]
if invalid:
return _json_error(f"Invalid sources: {invalid}. Valid: {sorted(valid_sources)}")
rows = max(1, min(rows, 50))
logger.info("search_papers called", extra={
"tool": "search_papers",
"query": query,
"sources": sources,
"rows": rows,
})
try:
result = await _search_all(query, sources, rows, type)
except Exception as exc:
logger.exception("search_papers failed")
return _json_error(f"Search failed: {exc}")
return _json_ok(result)
@mcp.tool()
def search_scopus(
query: str,
rows: int = 5,
view: str | None = None,
subscriber: bool = True,
) -> str:
"""Search Scopus documents using a Scopus advanced-search query.
Args:
query: Scopus advanced search query.
rows: Number of normalized results to return (max 50).
view: Optional Scopus view ("STANDARD" or "COMPLETE").
subscriber: Whether to use subscriber cursor navigation.
"""
try:
return _json_ok(_scopus.search(query, rows, view=view, subscriber=subscriber))
except DataSourceError as exc:
logger.error("search_scopus failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("search_scopus failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_scopus_abstract(
identifier: str,
id_type: str | None = None,
view: str = "META_ABS",
) -> str:
"""Retrieve Scopus abstract metadata.
Args:
identifier: EID, Scopus ID, DOI, PMID, or PII.
id_type: Optional pybliometrics ID type; auto-detected when omitted.
view: Scopus abstract view, usually "META_ABS".
"""
try:
return _json_ok(_scopus.get_abstract(identifier, id_type=id_type, view=view))
except DataSourceError as exc:
logger.error("get_scopus_abstract failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_scopus_abstract failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_scopus_citation_overview(
identifiers: list[str],
id_type: str = "scopus_id",
date: str | None = None,
citation: str | None = None,
) -> str:
"""Retrieve Scopus citation overview for one or more documents.
Args:
identifiers: Document identifiers.
id_type: Identifier type, e.g. "scopus_id", "doi", or "eid".
date: Optional year range such as "2020-2025".
citation: Optional exclusion mode, e.g. "exclude-self".
"""
try:
result = _scopus.get_citation_overview(
identifiers,
id_type=id_type,
date=date,
citation=citation,
)
return _json_ok(result)
except DataSourceError as exc:
logger.error("get_scopus_citation_overview failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_scopus_citation_overview failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def search_scopus_authors(query: str, rows: int = 5) -> str:
"""Search Scopus author profiles."""
try:
return _json_ok(_scopus.search_authors(query, rows))
except DataSourceError as exc:
logger.error("search_scopus_authors failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("search_scopus_authors failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_scopus_author(author_id: str, view: str = "ENHANCED") -> str:
"""Retrieve a Scopus author profile by author ID."""
try:
return _json_ok(_scopus.get_author(author_id, view=view))
except DataSourceError as exc:
logger.error("get_scopus_author failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_scopus_author failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def search_scopus_affiliations(query: str, rows: int = 5) -> str:
"""Search Scopus affiliations."""
try:
return _json_ok(_scopus.search_affiliations(query, rows))
except DataSourceError as exc:
logger.error("search_scopus_affiliations failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("search_scopus_affiliations failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_scopus_affiliation(affiliation_id: str, view: str = "STANDARD") -> str:
"""Retrieve a Scopus affiliation by affiliation ID."""
try:
return _json_ok(_scopus.get_affiliation(affiliation_id, view=view))
except DataSourceError as exc:
logger.error("get_scopus_affiliation failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_scopus_affiliation failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def search_scopus_serial_titles(
title: str | None = None,
issn: str | None = None,
publisher: str | None = None,
subject: str | None = None,
subject_code: str | None = None,
content: str | None = None,
open_access: str | None = None,
rows: int = 5,
view: str = "ENHANCED",
) -> str:
"""Search Scopus serial titles.
Args:
title: Serial title query.
issn: ISSN query.
publisher: Publisher query.
subject: Subject-area query.
subject_code: Subject-area code query.
content: Content type query.
open_access: Open-access filter.
rows: Number of results to return.
view: Scopus serial title view.
"""
query = {
"title": title,
"issn": issn,
"pub": publisher,
"subj": subject,
"subjCode": subject_code,
"content": content,
"oa": open_access,
}
try:
return _json_ok(_scopus.search_serial_titles(query, rows=rows, view=view))
except DataSourceError as exc:
logger.error("search_scopus_serial_titles failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("search_scopus_serial_titles failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_scopus_serial_title(
issn: str,
view: str = "ENHANCED",
years: str | None = None,
) -> str:
"""Retrieve a Scopus serial title by ISSN."""
try:
return _json_ok(_scopus.get_serial_title(issn, view=view, years=years))
except DataSourceError as exc:
logger.error("get_scopus_serial_title failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_scopus_serial_title failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_scopus_plumx_metrics(identifier: str, id_type: str) -> str:
"""Retrieve PlumX metrics for a document identifier."""
try:
return _json_ok(_scopus.get_plumx_metrics(identifier, id_type))
except DataSourceError as exc:
logger.error("get_scopus_plumx_metrics failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_scopus_plumx_metrics failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def search_sciencedirect(
query: str,
rows: int = 5,
view: str | None = None,
) -> str:
"""Search ScienceDirect article metadata."""
try:
return _json_ok(_sciencedirect.search(query, rows=rows, view=view))
except DataSourceError as exc:
logger.error("search_sciencedirect failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("search_sciencedirect failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_sciencedirect_article_metadata(
query: str,
rows: int = 5,
view: str | None = None,
) -> str:
"""Retrieve ScienceDirect article metadata with a metadata API query."""
try:
result = _sciencedirect.get_article_metadata(query, rows=rows, view=view)
return _json_ok(result)
except DataSourceError as exc:
logger.error("get_sciencedirect_article_metadata failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_sciencedirect_article_metadata failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
@mcp.tool()
def get_paper_by_id(id: str, id_type: str = "auto") -> str:
"""Get paper details by identifier (DOI, PMID, or arXiv ID).
Args:
id: Paper identifier. Auto-detected if id_type is "auto":
- Starts with "10." -> DOI (CrossRef)
- 7-8 digit number -> PMID (PubMed)
- YYMM.NNNNN format -> arXiv ID (arXiv)
id_type: Force identifier type ("doi", "pmid", "arxiv", or "auto").
Returns:
JSON string with detailed paper metadata.
"""
if not id or not id.strip():
return _json_error("Empty identifier")
try:
resolved_type = _resolve_id_type(id, id_type)
except ValueError as exc:
return _json_error(str(exc))
logger.info("get_paper_by_id called", extra={
"tool": "get_paper_by_id",
"id": id,
"id_type": resolved_type,
})
try:
if resolved_type == "doi":
result = _crossref.get_by_doi(id.strip())
elif resolved_type == "pmid":
result = _pubmed.get_by_pmid(id.strip())
elif resolved_type == "arxiv":
result = _arxiv.get_by_id(id.strip())
else:
return _json_error(f"Unsupported ID type: {resolved_type}")
except DataSourceError as exc:
logger.error("get_paper_by_id failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_paper_by_id failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
return _json_ok(result)
@mcp.tool()
def get_citation(id: str, id_type: str = "auto", style: str = "apa") -> str:
"""Get formatted citation for a paper.
Uses CrossRef content negotiation for DOI-based citations.
For PMID/arXiv IDs, fetches metadata first then generates a basic citation.
Args:
id: Paper identifier (DOI, PMID, or arXiv ID).
id_type: Force identifier type ("doi", "pmid", "arxiv", or "auto").
style: Citation style. Supported: apa, nature, ieee, harvard,
vancouver, chicago, mla.
Returns:
JSON string with the formatted citation.
"""
if not id or not id.strip():
return _json_error("Empty identifier")
try:
resolved_type = _resolve_id_type(id, id_type)
except ValueError as exc:
return _json_error(str(exc))
logger.info("get_citation called", extra={
"tool": "get_citation",
"id": id,
"id_type": resolved_type,
"style": style,
})
try:
if resolved_type == "doi":
citation = _crossref.get_citation(id.strip(), style=style)
return _json_ok({"id": id, "style": style, "citation": citation})
# For non-DOI IDs, fetch metadata and build a basic citation
if resolved_type == "pmid":
paper = _pubmed.get_by_pmid(id.strip())
elif resolved_type == "arxiv":
paper = _arxiv.get_by_id(id.strip())
else:
return _json_error(f"Unsupported ID type: {resolved_type}")
citation = _format_basic_citation(paper, style)
return _json_ok({"id": id, "style": style, "citation": citation})
except DataSourceError as exc:
logger.error("get_citation failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("get_citation failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
def _format_basic_citation(paper: dict, style: str) -> str:
"""Generate a basic citation string from unified paper metadata.
This is a fallback for non-DOI papers where CrossRef content
negotiation is not available.
"""
authors = paper.get("authors", [])
title = paper.get("title", "Untitled")
year = paper.get("year", "n.d.")
journal = paper.get("journal", "")
doi = paper.get("doi", "")
arxiv_id = paper.get("arxiv_id", "")
pmid = paper.get("pmid", "")
# Author formatting
if len(authors) > 3:
author_str = f"{authors[0]} et al."
elif authors:
author_str = ", ".join(authors)
else:
author_str = "Unknown"
if style == "nature":
parts = [f"{author_str}. {title}."]
if journal:
parts.append(f" *{journal}*.")
if year:
parts.append(f" ({year}).")
if doi:
parts.append(f" https://doi.org/{doi}")
return "".join(parts)
if style == "ieee":
ref = f"{author_str}, \"{title}\""
if journal:
ref += f", *{journal}*"
if year:
ref += f", {year}"
ref += "."
if doi:
ref += f" doi: {doi}."
return ref
# Default APA-like
parts = [f"{author_str} ({year}). {title}."]
if journal:
parts.append(f" *{journal}*.")
if doi:
parts.append(f" https://doi.org/{doi}")
elif arxiv_id:
parts.append(f" arXiv:{arxiv_id}")
elif pmid:
parts.append(f" PMID:{pmid}")
return "".join(parts)
@mcp.tool()
def lookup_mesh(term: str) -> str:
"""Lookup MeSH (Medical Subject Headings) terms.
Queries the MeSH database via NCBI E-utilities to find matching
descriptor names and unique IDs.
Args:
term: Search term to look up in the MeSH vocabulary.
Returns:
JSON string with matching MeSH descriptors.
"""
if not term or not term.strip():
return _json_error("Empty MeSH lookup term")
logger.info("lookup_mesh called", extra={
"tool": "lookup_mesh",
"term": term,
})
try:
result = _pubmed.lookup_mesh(term.strip())
except DataSourceError as exc:
logger.error("lookup_mesh failed: %s", exc)
return _json_error(str(exc), source=exc.source)
except Exception as exc:
logger.exception("lookup_mesh failed unexpectedly")
return _json_error(f"Unexpected error: {exc}")
return _json_ok(result)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
mcp.run(transport="stdio")
[pubmed]
email = "" # Set your PubMed email here or via PUBMED_EMAIL env var
api_key = ""
cache_dir = "~/.cache/academic-search"
cache_ttl = 86400
[crossref]
mailto = ""
timeout = 15
[arxiv]
timeout = 30
[general]
default_rows = 5
max_rows = 50
Unified Academic Search MCP Server
统一的学术搜索 MCP 服务器,整合 CrossRef、PubMed、arXiv、Scopus、ScienceDirect 数据源。
工具
| 工具 | 功能 |
|---|---|
search_papers | 统一搜索,支持多数据源并发 |
get_paper_by_id | 按 DOI/PMID/arXiv ID 获取详情 |
get_citation | 格式化引用 (apa/nature/ieee 等) |
lookup_mesh | MeSH 词表查询 |
search_scopus | Scopus 高级检索 |
get_scopus_abstract | Scopus 摘要/详情元数据 |
get_scopus_citation_overview | Scopus 引用概览 |
search_scopus_authors / get_scopus_author | 作者检索与详情 |
search_scopus_affiliations / get_scopus_affiliation | 机构检索与详情 |
search_scopus_serial_titles / get_scopus_serial_title | 期刊/连续出版物检索与详情 |
get_scopus_plumx_metrics | PlumX 指标 |
search_sciencedirect | ScienceDirect 检索 |
get_sciencedirect_article_metadata | ScienceDirect 文章元数据 |
配置
环境变量:
PUBMED_EMAIL- 必填,NCBI 要求NCBI_API_KEY- 可选,提升速率限制- Elsevier / Scopus / ScienceDirect: 复用
pybliometrics配置文件,默认位置为~/.config/pybliometrics.cfg
search_papers 默认检索 CrossRef、PubMed、arXiv。Scopus / ScienceDirect 是可选 provider:在 sources 显式传入 scopus / sciencedirect,或调用专用 Scopus / ScienceDirect 工具时才会访问 Elsevier API。这样可以避免默认搜索无意消耗 Elsevier API 配额;若本机缺少 pybliometrics 配置,会在返回 JSON 的 errors 字段中给出对应数据源错误。
配置文件: config.toml
使用
插件会通过 uv run --no-project --directory <mcp-server> --with ... python academic_search_server.py 启动隔离运行环境。工具通过 academic-search skill 调用。
mcp>=1.0.0,<2.0.0
requests>=2.28.0,<3.0.0
toml>=0.10.2,<2.0.0
lxml>=4.9.0,<6.0.0
pybliometrics>=4.4.1,<5.0.0
"""Data source modules for academic search."""
from .crossref import CrossRefSource
from .pubmed import PubMedSource
from .arxiv import ArxivSource
from .scopus import ScopusSource
from .sciencedirect import ScienceDirectSource
__all__ = [
"CrossRefSource",
"PubMedSource",
"ArxivSource",
"ScopusSource",
"ScienceDirectSource",
]
"""arXiv data source via REST API (Atom XML feed)."""
import re
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime
from utils.config import get_config
from utils.errors import DataSourceError
from utils.logging import setup_logging
ARXIV_API_URL = "https://export.arxiv.org/api/query"
ARXIV_NS = {
"atom": "http://www.w3.org/2005/Atom",
"arxiv": "http://arxiv.org/schemas/atom",
}
_MIN_REQUEST_INTERVAL = 3.0 # seconds between requests
_SOURCE_NAME = "arxiv"
logger = setup_logging("INFO")
class ArxivSource:
"""arXiv search and retrieval via the Atom API."""
def __init__(self):
self._last_request_time: float = 0.0
self._timeout: int | None = None
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
def search(
self,
query: str,
rows: int = 5,
categories: list[str] | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> dict:
"""Search arXiv.
Parameters
----------
query : str
Free-text search query.
rows : int
Max number of results to return.
categories : list[str] | None
arXiv categories to restrict to (e.g. ["cs.AI", "cs.LG"]).
date_from : str | None
Start date in YYYY-MM-DD format.
date_to : str | None
End date in YYYY-MM-DD format.
Returns
-------
dict
{"results": [...], "total": int, "source": "arxiv"}
"""
search_query = self._build_query(query, categories)
date_filter = self._build_date_filter(date_from, date_to)
params = {
"search_query": search_query,
"start": 0,
"max_results": rows,
"sortBy": "relevance",
"sortOrder": "descending",
}
if date_filter:
params["search_query"] = f"{search_query}+AND+{date_filter}"
raw = self._request(params)
results = self._parse_feed(raw)
return {
"results": results,
"total": len(results),
"source": _SOURCE_NAME,
}
def get_by_id(self, arxiv_id: str) -> dict:
"""Retrieve a single paper by arXiv ID.
Parameters
----------
arxiv_id : str
arXiv identifier, e.g. "2401.12345" or "2401.12345v1".
Returns
-------
dict
Paper record in unified format.
"""
clean_id = self._normalize_id(arxiv_id)
params = {
"id_list": clean_id,
"max_results": 1,
}
raw = self._request(params)
results = self._parse_feed(raw)
if not results:
raise DataSourceError(
_SOURCE_NAME,
f"Paper not found: {arxiv_id}",
)
return results[0]
# ------------------------------------------------------------------
# Query construction
# ------------------------------------------------------------------
@staticmethod
def _build_query(user_query: str, categories: list[str] | None = None) -> str:
"""Build the search_query parameter.
Combines user query with optional category restrictions.
Returns a string with +AND+/+OR+ connectors (URL-ready).
"""
parts: list[str] = []
parts.append(f"({user_query})")
if categories:
cat_expr = " OR ".join(f"cat:{cat}" for cat in categories)
parts.append(f"({cat_expr})")
combined = " AND ".join(parts)
# Replace connectors and spaces for URL embedding
combined = (
combined.replace(" AND ", "+AND+")
.replace(" OR ", "+OR+")
.replace(" ", "+")
)
return combined
@staticmethod
def _build_date_filter(
date_from: str | None, date_to: str | None
) -> str:
"""Build submittedDate filter expression.
The arXiv API requires the literal +TO+ syntax and 14-digit
timestamps in the format YYYYMMDDHHMM.
Returns empty string when no date bounds are given.
"""
if not date_from and not date_to:
return ""
# Default boundaries
start_ts = "000000000000"
end_ts = "999912312359"
if date_from:
start_ts = _date_to_ts(date_from)
if date_to:
end_ts = _date_to_ts(date_to, end_of_day=True)
return f"submittedDate:[{start_ts}+TO+{end_ts}]"
# ------------------------------------------------------------------
# HTTP
# ------------------------------------------------------------------
def _request(self, params: dict) -> str:
"""Execute an HTTP GET to the arXiv API with rate limiting."""
self._enforce_rate_limit()
url = f"{ARXIV_API_URL}?{urllib.parse.urlencode(params)}"
timeout = self._get_timeout()
logger.debug("arXiv request: %s", url)
try:
req = urllib.request.Request(url)
req.add_header("User-Agent", "academic-search/1.0")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
if exc.code in (429, 503):
raise DataSourceError(
_SOURCE_NAME,
f"Rate limited or unavailable (HTTP {exc.code})",
original_error=exc,
) from exc
raise DataSourceError(
_SOURCE_NAME,
f"HTTP error {exc.code}: {exc.reason}",
original_error=exc,
) from exc
except urllib.error.URLError as exc:
raise DataSourceError(
_SOURCE_NAME,
f"Network error: {exc.reason}",
original_error=exc,
) from exc
except TimeoutError as exc:
raise DataSourceError(
_SOURCE_NAME,
f"Request timed out after {timeout}s",
original_error=exc,
) from exc
def _enforce_rate_limit(self) -> None:
elapsed = time.monotonic() - self._last_request_time
if elapsed < _MIN_REQUEST_INTERVAL:
time.sleep(_MIN_REQUEST_INTERVAL - elapsed)
self._last_request_time = time.monotonic()
def _get_timeout(self) -> int:
if self._timeout is None:
self._timeout = get_config().arxiv_timeout
return self._timeout
# ------------------------------------------------------------------
# XML parsing
# ------------------------------------------------------------------
def _parse_feed(self, xml_text: str) -> list[dict]:
"""Parse arXiv Atom XML into a list of unified result dicts."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise DataSourceError(
_SOURCE_NAME,
f"Malformed XML response: {exc}",
original_error=exc,
) from exc
entries: list[dict] = []
for entry in root.findall("atom:entry", ARXIV_NS):
parsed = self._parse_entry(entry)
if parsed:
entries.append(parsed)
return entries
def _parse_entry(self, entry: ET.Element) -> dict | None:
"""Extract a single paper record from an Atom <entry>."""
arxiv_id_raw = _text(entry, "atom:id", ARXIV_NS)
if not arxiv_id_raw:
return None
title = _text(entry, "atom:title", ARXIV_NS) or ""
title = re.sub(r"\s+", " ", title).strip()
summary = _text(entry, "atom:summary", ARXIV_NS) or ""
summary = re.sub(r"\s+", " ", summary).strip()
authors = [
name
for name in (
_text(author, "atom:name", ARXIV_NS)
for author in entry.findall("atom:author", ARXIV_NS)
)
if name
]
primary_cat = entry.find("arxiv:primary_category", ARXIV_NS)
categories = []
if primary_cat is not None:
term = primary_cat.get("term")
if term:
categories.append(term)
# Also collect secondary categories from atom:category elements
for cat in entry.findall("atom:category", ARXIV_NS):
scheme = cat.get("scheme", "")
term = cat.get("term", "")
if term and "arxiv" in scheme.lower() and term not in categories:
categories.append(term)
published = _text(entry, "atom:published", ARXIV_NS) or ""
year = _extract_year(published)
pdf_url = ""
for link in entry.findall("atom:link", ARXIV_NS):
if link.get("title") == "pdf":
pdf_url = link.get("href", "")
break
arxiv_id = self._normalize_id(arxiv_id_raw)
return {
"title": title,
"authors": authors,
"year": year,
"arxiv_id": arxiv_id,
"categories": categories,
"abstract": summary,
"pdf_url": pdf_url,
"source": _SOURCE_NAME,
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _normalize_id(raw: str) -> str:
"""Strip URL prefix and version suffix from an arXiv ID.
http://arxiv.org/abs/2401.12345v1 -> 2401.12345
"""
arxiv_id = raw.strip()
# Remove URL prefix
match = re.search(r"(\d{4}\.\d{4,5})(v\d+)?$", arxiv_id)
if match:
return match.group(1)
# Fallback: strip known prefixes
for prefix in ("https://arxiv.org/abs/", "http://arxiv.org/abs/"):
if arxiv_id.startswith(prefix):
arxiv_id = arxiv_id[len(prefix):]
break
# Strip version
arxiv_id = re.sub(r"v\d+$", "", arxiv_id)
return arxiv_id
# ------------------------------------------------------------------
# Module-level helpers
# ------------------------------------------------------------------
def _text(parent: ET.Element, xpath: str, ns: dict) -> str | None:
"""Return stripped text of a sub-element, or None."""
el = parent.find(xpath, ns)
if el is not None and el.text:
return el.text.strip()
return None
def _date_to_ts(date_str: str, end_of_day: bool = False) -> str:
"""Convert YYYY-MM-DD to YYYYMMDDHHMM (14-digit timestamp).
Parameters
----------
date_str : str
Date in YYYY-MM-DD format.
end_of_day : bool
If True, use 2359 as HHMM; otherwise 0000.
"""
dt = datetime.strptime(date_str, "%Y-%m-%d")
suffix = "2359" if end_of_day else "0000"
return dt.strftime("%Y%m%d") + suffix
def _extract_year(published: str) -> int | None:
"""Extract year from ISO datetime string (e.g. 2024-01-15T...)."""
try:
return int(published[:4])
except (ValueError, IndexError):
return None
"""CrossRef data source for academic search."""
from urllib.parse import quote
import requests
from utils.config import get_config
from utils.errors import DataSourceError
CROSSREF_API = "https://api.crossref.org"
class CrossRefSource:
"""CrossRef API wrapper with unified result format."""
SOURCE_NAME = "crossref"
def __init__(self):
config = get_config()
mailto = config.crossref_mailto or "user@example.com"
self._headers = {
"User-Agent": f"ClaudeCode-MCP-Crossref/1.0 (mailto:{mailto})",
}
self._timeout = config.crossref_timeout
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def search(
self, query: str, rows: int = 5, filter_type: str | None = None
) -> dict:
"""Search CrossRef works.
Args:
query: Keywords, author name, title, DOI prefix, etc.
rows: Number of results (max 50).
filter_type: Optional work type filter, e.g. "journal-article".
Returns:
{"total": int, "results": [unified_result, ...]}
"""
params: dict = {"query": query, "rows": min(rows, 50)}
if filter_type:
params["filter"] = f"type:{filter_type}"
data = self._request("/works", params=params)
items = data.get("items", [])
total = data.get("total-results", 0)
results = [self._normalize_search_item(item) for item in items]
return {"total": total, "results": results}
def get_by_doi(self, doi: str) -> dict:
"""Get detailed metadata for a single work by DOI.
Args:
doi: Digital Object Identifier (e.g. "10.1038/nature12373").
Returns:
Unified result dict with extra fields (abstract, volume, etc.).
"""
data = self._request(f"/works/{quote(doi, safe='/')}")
return self._normalize_detail_item(data)
def get_citation(self, doi: str, style: str = "apa") -> str:
"""Return a formatted citation string via CrossRef content negotiation.
Args:
doi: Digital Object Identifier.
style: Citation style (apa, nature, vancouver, ieee, etc.).
Returns:
Formatted citation string.
"""
url = f"{CROSSREF_API}/works/{quote(doi, safe='/')}/transform"
headers = {
**self._headers,
"Accept": f"text/x-bibliography; style={style}",
}
try:
resp = requests.get(url, headers=headers, timeout=self._timeout)
except requests.RequestException as exc:
raise DataSourceError(
self.SOURCE_NAME,
f"Network error fetching citation for {doi}: {exc}",
original_error=exc,
) from exc
if resp.status_code == 404:
return f"Citation not available for DOI: {doi}"
if resp.status_code == 406:
raise DataSourceError(
self.SOURCE_NAME,
f"Unsupported citation style: {style}",
)
try:
resp.raise_for_status()
except requests.HTTPError as exc:
raise DataSourceError(
self.SOURCE_NAME,
f"HTTP {resp.status_code} fetching citation for {doi}",
original_error=exc,
) from exc
return resp.text.strip()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _request(self, path: str, params: dict | None = None) -> dict:
"""Issue GET to CrossRef API and return the ``message`` payload."""
url = f"{CROSSREF_API}{path}"
try:
resp = requests.get(
url, params=params, headers=self._headers, timeout=self._timeout
)
resp.raise_for_status()
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else "?"
raise DataSourceError(
self.SOURCE_NAME,
f"HTTP {status} from {url}",
original_error=exc,
) from exc
except requests.RequestException as exc:
raise DataSourceError(
self.SOURCE_NAME,
f"Network error calling {url}: {exc}",
original_error=exc,
) from exc
return resp.json().get("message", {})
# ------------------------------------------------------------------
# Normalization
# ------------------------------------------------------------------
@staticmethod
def _extract_authors(author_list: list[dict], limit: int = 0) -> list[str]:
"""Convert CrossRef author entries to ``["Given Family", ...]`` list.
Args:
author_list: Raw ``author`` array from CrossRef.
limit: Max authors to include; 0 = all.
"""
subset = author_list[:limit] if limit else author_list
names = [
f"{a.get('given', '')} {a.get('family', '')}".strip()
for a in subset
]
if limit and len(author_list) > limit:
names.append("et al.")
return names
@staticmethod
def _extract_year(item: dict) -> int | None:
"""Best-effort publication year extraction."""
for key in ("published-print", "published-online", "created"):
parts = item.get(key, {}).get("date-parts", [[None]])
year = parts[0][0] if parts and parts[0] else None
if year is not None:
return year
return None
def _normalize_search_item(self, item: dict) -> dict:
"""Map a CrossRef work item to the unified search result format."""
return {
"title": (item.get("title") or [""])[0],
"authors": self._extract_authors(item.get("author", []), limit=5),
"year": self._extract_year(item),
"doi": item.get("DOI"),
"journal": (item.get("container-title") or [""])[0],
"source": self.SOURCE_NAME,
"citation_count": item.get("is-referenced-by-count", 0),
}
def _normalize_detail_item(self, item: dict) -> dict:
"""Map a CrossRef work item to the unified detail result format."""
base = self._normalize_search_item(item)
base.update({
"authors": self._extract_authors(item.get("author", [])),
"abstract": item.get("abstract", ""),
"volume": item.get("volume", ""),
"issue": item.get("issue", ""),
"pages": item.get("page", ""),
"publisher": item.get("publisher", ""),
"type": item.get("type"),
"references_count": item.get("references-count", 0),
"url": item.get("URL"),
})
return base
"""Shared helpers for pybliometrics-backed Elsevier sources."""
from __future__ import annotations
from pathlib import Path
from threading import Lock
from typing import Any
from pybliometrics import init as pybliometrics_init
from pybliometrics.utils.constants import CONFIG_FILE
from utils.errors import DataSourceError
_init_lock = Lock()
_initialised = False
def ensure_pybliometrics_config(source: str) -> None:
"""Initialise pybliometrics from its configured file.
pybliometrics creates a config interactively when the file is absent. MCP
servers cannot prompt, so missing or invalid config is reported explicitly.
"""
config_path = Path(CONFIG_FILE)
if not config_path.exists():
raise DataSourceError(
source,
f"pybliometrics config not found at {config_path}",
)
global _initialised
with _init_lock:
if _initialised:
return
try:
pybliometrics_init(config_path=config_path)
except (FileNotFoundError, ValueError) as exc:
raise DataSourceError(
source,
f"pybliometrics config is invalid at {config_path}: {exc}",
original_error=exc,
) from exc
_initialised = True
def record_to_dict(value: Any) -> Any:
"""Recursively convert pybliometrics records into JSON-safe structures."""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if hasattr(value, "_asdict"):
return {k: record_to_dict(v) for k, v in value._asdict().items()}
if isinstance(value, dict):
return {str(k): record_to_dict(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [record_to_dict(v) for v in value]
return value
def safe_attr(obj: Any, name: str) -> Any:
"""Read an optional pybliometrics property."""
try:
return getattr(obj, name)
except (AttributeError, KeyError, TypeError):
return None
def split_semicolon(value: str | None) -> list[str]:
"""Split pybliometrics semicolon-joined fields."""
if not value:
return []
return [item.strip() for item in value.split(";") if item.strip()]
def year_from_date(value: str | None) -> int | None:
"""Extract a publication year from an ISO-like date string."""
if not value or len(value) < 4:
return None
try:
return int(value[:4])
except ValueError:
return None
"""PubMed data source via NCBI E-utilities API."""
from __future__ import annotations
import time
import xml.etree.ElementTree as ET
from typing import Any
import requests
from utils.config import get_config
from utils.errors import DataSourceError
from utils.logging import setup_logging
logger = setup_logging()
BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/"
SOURCE_NAME = "pubmed"
# Rate limit: 3 req/s without key, 10 req/s with key
_REQ_INTERVAL_WITH_KEY = 0.11
_REQ_INTERVAL_WITHOUT_KEY = 0.35
_last_request_ts: float = 0.0
def _throttle(api_key: str) -> None:
"""Enforce NCBI rate limits."""
global _last_request_ts
interval = _REQ_INTERVAL_WITH_KEY if api_key else _REQ_INTERVAL_WITHOUT_KEY
elapsed = time.monotonic() - _last_request_ts
if elapsed < interval:
time.sleep(interval - elapsed)
_last_request_ts = time.monotonic()
def _get(endpoint: str, params: dict[str, Any], timeout: int = 30) -> requests.Response:
"""Send GET request to NCBI E-utilities with throttling and error handling."""
cfg = get_config()
api_key = cfg.pubmed_api_key
_throttle(api_key)
merged = dict(params)
if cfg.pubmed_email:
merged["email"] = cfg.pubmed_email
if api_key:
merged["api_key"] = api_key
url = BASE_URL + endpoint
try:
resp = requests.get(url, params=merged, timeout=timeout)
resp.raise_for_status()
except requests.Timeout as exc:
raise DataSourceError(SOURCE_NAME, f"Request timed out: {url}", exc) from exc
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else "?"
raise DataSourceError(
SOURCE_NAME, f"HTTP {status} from {url}", exc
) from exc
except requests.RequestException as exc:
raise DataSourceError(SOURCE_NAME, f"Request failed: {url}", exc) from exc
return resp
def _parse_article(article: ET.Element) -> dict[str, Any]:
"""Parse a single PubmedArticle XML element into the unified result dict."""
citation = article.find("MedlineCitation")
if citation is None:
raise DataSourceError(SOURCE_NAME, "Missing MedlineCitation in article XML")
pmid_el = citation.find("PMID")
pmid = pmid_el.text.strip() if pmid_el is not None and pmid_el.text else ""
art = citation.find("Article")
if art is None:
raise DataSourceError(SOURCE_NAME, f"Missing Article for PMID {pmid}")
# Title
title_el = art.find("ArticleTitle")
title = title_el.text.strip() if title_el is not None and title_el.text else ""
# Authors
authors: list[str] = []
author_list = art.find("AuthorList")
if author_list is not None:
for author in author_list.findall("Author"):
last = author.find("LastName")
fore = author.find("ForeName")
if last is not None and last.text:
name = last.text.strip()
if fore is not None and fore.text:
name = f"{name} {fore.text.strip()}"
authors.append(name)
elif (collective := author.find("CollectiveName")) is not None and collective.text:
authors.append(collective.text.strip())
# Abstract
abstract_parts: list[str] = []
abstract_el = art.find("Abstract")
if abstract_el is not None:
for text_el in abstract_el.findall("AbstractText"):
label = text_el.get("Label", "")
content = "".join(text_el.itertext()).strip()
if label and content:
abstract_parts.append(f"{label}: {content}")
elif content:
abstract_parts.append(content)
abstract = " ".join(abstract_parts)
# Journal
journal_el = art.find("Journal")
journal = ""
year = None
if journal_el is not None:
title_el = journal_el.find("Title")
if title_el is not None and title_el.text:
journal = title_el.text.strip()
# Year from JournalIssue/PubDate
issue = journal_el.find("JournalIssue")
if issue is not None:
pub_date = issue.find("PubDate")
if pub_date is not None:
year_el = pub_date.find("Year")
if year_el is not None and year_el.text:
try:
year = int(year_el.text.strip())
except ValueError:
pass
if year is None:
medline_date = pub_date.find("MedlineDate")
if medline_date is not None and medline_date.text:
# Extract first 4-digit year from string like "2024 Jan-Feb"
import re
m = re.search(r"\d{4}", medline_date.text)
if m:
year = int(m.group())
# DOI
doi = ""
for eloi in art.findall("ELocationID"):
if eloi.get("EIdType") == "doi" and eloi.text:
doi = eloi.text.strip()
break
return {
"title": title,
"authors": authors,
"year": year,
"pmid": pmid,
"doi": doi,
"journal": journal,
"abstract": abstract,
"source": SOURCE_NAME,
}
class PubMedSource:
"""PubMed data source providing search, fetch, and MeSH lookup."""
def search(
self,
query: str,
rows: int = 5,
sort: str = "relevance",
) -> dict[str, Any]:
"""Search PubMed and return structured results.
Args:
query: PubMed search query string.
rows: Number of results to return.
sort: Sort order -- "relevance" (Best Match) or "date".
Returns:
Dict with keys: total, query, results (list of unified result dicts).
"""
if not query or not query.strip():
raise DataSourceError(SOURCE_NAME, "Empty search query")
cfg = get_config()
if not cfg.pubmed_email:
raise DataSourceError(
SOURCE_NAME,
"PubMed email not configured. Set PUBMED_EMAIL env var or [pubmed].email in config.toml",
)
rows = min(rows, cfg.max_rows)
sort_param = "relevance" if sort == "relevance" else "pub_date"
# Step 1: esearch to get WebEnv + query_key
search_params: dict[str, Any] = {
"db": "pubmed",
"term": query.strip(),
"retmax": rows,
"usehistory": "y",
"retmode": "xml",
"sort": sort_param,
}
resp = _get("esearch.fcgi", search_params)
root = ET.fromstring(resp.content)
count_el = root.find("Count")
total = int(count_el.text) if count_el is not None and count_el.text else 0
web_env_el = root.find("WebEnv")
query_key_el = root.find("QueryKey")
if web_env_el is None or query_key_el is None or not web_env_el.text or not query_key_el.text:
# No results
return {"total": 0, "query": query, "results": []}
web_env = web_env_el.text.strip()
query_key = query_key_el.text.strip()
# Step 2: efetch to get article details
fetch_params: dict[str, Any] = {
"db": "pubmed",
"query_key": query_key,
"WebEnv": web_env,
"retmax": rows,
"retmode": "xml",
"rettype": "abstract",
}
resp = _get("efetch.fcgi", fetch_params)
fetch_root = ET.fromstring(resp.content)
results: list[dict[str, Any]] = []
for article in fetch_root.findall("PubmedArticle"):
try:
results.append(_parse_article(article))
except DataSourceError as exc:
logger.warning("Failed to parse article: %s", exc)
continue
return {"total": total, "query": query, "results": results}
def get_by_pmid(self, pmid: str) -> dict[str, Any]:
"""Fetch a single article by PMID.
Args:
pmid: PubMed ID (numeric string).
Returns:
Unified result dict for the article.
Raises:
DataSourceError: If PMID is invalid or article not found.
"""
if not pmid or not pmid.strip().isdigit():
raise DataSourceError(SOURCE_NAME, f"Invalid PMID: {pmid}")
cfg = get_config()
if not cfg.pubmed_email:
raise DataSourceError(
SOURCE_NAME,
"PubMed email not configured. Set PUBMED_EMAIL env var or [pubmed].email in config.toml",
)
fetch_params: dict[str, Any] = {
"db": "pubmed",
"id": pmid.strip(),
"retmode": "xml",
"rettype": "abstract",
}
resp = _get("efetch.fcgi", fetch_params)
root = ET.fromstring(resp.content)
article = root.find("PubmedArticle")
if article is None:
raise DataSourceError(SOURCE_NAME, f"PMID {pmid} not found")
return _parse_article(article)
def lookup_mesh(self, term: str) -> dict[str, Any]:
"""Look up a MeSH descriptor by term.
Queries the MeSH database via E-utilities to find matching
descriptor names and unique IDs.
Args:
term: Search term to look up in MeSH.
Returns:
Dict with keys: term, results (list of {name, mesh_id, ui}).
"""
if not term or not term.strip():
raise DataSourceError(SOURCE_NAME, "Empty MeSH lookup term")
cfg = get_config()
if not cfg.pubmed_email:
raise DataSourceError(
SOURCE_NAME,
"PubMed email not configured. Set PUBMED_EMAIL env var or [pubmed].email in config.toml",
)
# Use esearch on MeSH database
search_params: dict[str, Any] = {
"db": "mesh",
"term": term.strip(),
"retmax": 10,
"retmode": "xml",
}
resp = _get("esearch.fcgi", search_params)
root = ET.fromstring(resp.content)
id_list = root.find("IdList")
if id_list is None or len(id_list) == 0:
return {"term": term, "results": []}
ids = [id_el.text.strip() for id_el in id_list.findall("Id") if id_el.text]
if not ids:
return {"term": term, "results": []}
# efetch from mesh db to get descriptor details
fetch_params: dict[str, Any] = {
"db": "mesh",
"id": ",".join(ids),
"retmode": "xml",
}
resp = _get("efetch.fcgi", fetch_params)
fetch_root = ET.fromstring(resp.content)
results: list[dict[str, str]] = []
for descriptor in fetch_root.findall(".//DescriptorRecord"):
name_el = descriptor.find("DescriptorName/String")
ui_el = descriptor.find("DescriptorUI")
name = name_el.text.strip() if name_el is not None and name_el.text else ""
ui = ui_el.text.strip() if ui_el is not None and ui_el.text else ""
if name:
results.append({"name": name, "mesh_id": ui, "ui": ui})
return {"term": term, "results": results}
"""ScienceDirect data source via pybliometrics."""
from __future__ import annotations
from typing import Any
from pybliometrics.utils import URLS, get_content
from utils.errors import DataSourceError
from .elsevier_common import ensure_pybliometrics_config, year_from_date
SOURCE_NAME = "sciencedirect"
class ScienceDirectSource:
"""pybliometrics-backed ScienceDirect metadata operations."""
SOURCE_NAME = SOURCE_NAME
def search(
self,
query: str,
rows: int = 5,
view: str | None = None,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Search ScienceDirect article metadata and return the requested page."""
if not query or not query.strip():
raise DataSourceError(SOURCE_NAME, "Empty search query")
rows = max(1, min(rows, 50))
def run() -> dict[str, Any]:
data = self._search_api(
"ScienceDirectSearch",
{
"query": query.strip(),
"count": rows,
"start": 0,
"view": view or "STANDARD",
},
)
records = _search_entries(data)
return {
"total": _total_results(data),
"query": query,
"source": SOURCE_NAME,
"results": [self._normalize_search_entry(r) for r in records[:rows]],
}
return self._run("ScienceDirect search", run)
def get_article_metadata(
self,
query: str,
rows: int = 5,
view: str | None = None,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve ScienceDirect article metadata using a metadata API query."""
if not query or not query.strip():
raise DataSourceError(SOURCE_NAME, "Empty article metadata query")
rows = max(1, min(rows, 50))
def run() -> dict[str, Any]:
data = self._search_api(
"ArticleMetadata",
{
"query": query.strip(),
"count": rows,
"start": 0,
"view": view or "STANDARD",
},
)
records = _search_entries(data)
return {
"total": _total_results(data),
"query": query,
"source": SOURCE_NAME,
"results": [self._normalize_metadata_entry(r) for r in records[:rows]],
}
return self._run("ScienceDirect article metadata retrieval", run)
def _run(self, operation: str, callback):
ensure_pybliometrics_config(SOURCE_NAME)
try:
return callback()
except DataSourceError:
raise
except Exception as exc:
raise DataSourceError(
SOURCE_NAME,
f"{operation} failed: {exc}",
original_error=exc,
) from exc
@staticmethod
def _search_api(api: str, params: dict[str, Any]) -> dict[str, Any]:
response = get_content(URLS[api], api, params=params)
return response.json()
@staticmethod
def _normalize_search_entry(data: dict[str, Any]) -> dict[str, Any]:
links = _links(data.get("link"))
return {
"title": data.get("dc:title"),
"authors": _authors(data),
"year": year_from_date(data.get("prism:coverDate")),
"doi": _doi(data),
"pii": data.get("pii"),
"journal": data.get("prism:publicationName"),
"volume": data.get("prism:volume"),
"pages": _join_pages(data.get("prism:startingPage"), data.get("prism:endingPage")),
"openaccess_status": data.get("openaccess"),
"link": links.get("scidir"),
"api_link": links.get("self") or data.get("prism:url"),
"source": SOURCE_NAME,
}
@staticmethod
def _normalize_metadata_entry(data: dict[str, Any]) -> dict[str, Any]:
links = _links(data.get("link"))
return {
"title": data.get("dc:title"),
"authors": _authors(data),
"year": year_from_date(data.get("prism:coverDate")),
"doi": _doi(data),
"eid": data.get("eid"),
"pii": data.get("pii"),
"abstract": data.get("dc:description"),
"journal": data.get("prism:publicationName"),
"pages": _join_pages(data.get("prism:startingPage"), data.get("prism:endingPage")),
"aggregation_type": data.get("prism:aggregationType"),
"publication_type": data.get("prism:publicationType"),
"author_keywords": data.get("authkeywords"),
"openaccess_status": data.get("openaccess"),
"link": links.get("scidir") or links.get("self"),
"api_link": links.get("self") or data.get("prism:url"),
"source": SOURCE_NAME,
}
def _search_entries(data: dict[str, Any]) -> list[dict[str, Any]]:
entries = data.get("search-results", {}).get("entry", [])
return [
entry
for entry in _as_list(entries)
if isinstance(entry, dict)
and (entry.get("dc:title") or entry.get("prism:doi") or entry.get("pii"))
]
def _total_results(data: dict[str, Any]) -> int:
total = data.get("search-results", {}).get("opensearch:totalResults", 0)
return _int_or_none(total) or 0
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _authors(data: dict[str, Any]) -> list[str]:
authors = []
author_data = data.get("authors", {}).get("author", [])
for item in _as_list(author_data):
name = item.get("$") if isinstance(item, dict) else item
if name:
authors.append(name)
creator = data.get("dc:creator")
if isinstance(creator, list):
authors.extend(item.get("$") for item in creator if isinstance(item, dict))
elif isinstance(creator, str) and not authors:
authors.append(creator)
return [a for a in authors if a]
def _links(value: Any) -> dict[str, str]:
out = {}
for item in _as_list(value):
if not isinstance(item, dict):
continue
ref = item.get("@ref") or item.get("rel")
href = item.get("@href") or item.get("href")
if ref and href:
out[ref] = href
return out
def _doi(data: dict[str, Any]) -> str | None:
if data.get("prism:doi"):
return data["prism:doi"]
identifier = data.get("dc:identifier")
if isinstance(identifier, str) and identifier.startswith("doi:"):
return identifier[4:]
return None
def _join_pages(start: str | None, end: str | None) -> str:
if start and end:
return f"{start}-{end}"
return start or end or ""
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
"""Scopus data source via pybliometrics."""
from __future__ import annotations
from typing import Any
from pybliometrics.scopus import (
AbstractRetrieval,
AffiliationRetrieval,
AuthorRetrieval,
CitationOverview,
PlumXMetrics,
SerialTitleISSN,
)
from pybliometrics.utils import URLS, get_content
from utils.errors import DataSourceError
from .elsevier_common import (
ensure_pybliometrics_config,
record_to_dict,
safe_attr,
year_from_date,
)
SOURCE_NAME = "scopus"
class ScopusSource:
"""pybliometrics-backed Scopus operations."""
SOURCE_NAME = SOURCE_NAME
def search(
self,
query: str,
rows: int = 5,
view: str | None = None,
refresh: bool | int = False,
subscriber: bool = True,
) -> dict[str, Any]:
"""Search Scopus documents and return only the requested first page."""
if not query or not query.strip():
raise DataSourceError(SOURCE_NAME, "Empty search query")
rows = max(1, min(rows, 50))
def run() -> dict[str, Any]:
effective_view = view or ("COMPLETE" if subscriber else "STANDARD")
data = self._search_api(
"ScopusSearch",
{"query": query.strip(), "count": rows, "start": 0, "view": effective_view},
)
records = _search_entries(data)
return {
"total": _total_results(data),
"query": query,
"source": SOURCE_NAME,
"results": [self._normalize_search_entry(r) for r in records[:rows]],
}
return self._run("Scopus search", run)
def get_abstract(
self,
identifier: str,
id_type: str | None = None,
view: str = "META_ABS",
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve Scopus abstract metadata by EID, Scopus ID, DOI, PMID, or PII."""
if not identifier or not identifier.strip():
raise DataSourceError(SOURCE_NAME, "Empty identifier")
def run() -> dict[str, Any]:
paper = AbstractRetrieval(
identifier.strip(),
id_type=id_type,
view=view,
refresh=refresh,
)
return self._normalize_abstract(paper)
return self._run("Scopus abstract retrieval", run)
def get_citation_overview(
self,
identifiers: list[str],
id_type: str = "scopus_id",
date: str | None = None,
citation: str | None = None,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve Scopus citation overview for one or more documents."""
clean = [i.strip() for i in identifiers if i and i.strip()]
if not clean:
raise DataSourceError(SOURCE_NAME, "Empty identifier list")
def run() -> dict[str, Any]:
overview = CitationOverview(
clean,
date=date,
id_type=id_type,
citation=citation,
refresh=refresh,
)
return self._normalize_citation_overview(overview)
return self._run("Scopus citation overview", run)
def search_authors(
self,
query: str,
rows: int = 5,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Search Scopus author profiles and return only the requested first page."""
if not query or not query.strip():
raise DataSourceError(SOURCE_NAME, "Empty author search query")
rows = max(1, min(rows, 50))
def run() -> dict[str, Any]:
data = self._search_api(
"AuthorSearch",
{"query": query.strip(), "count": rows, "start": 0},
)
records = _search_entries(data)
return {
"total": _total_results(data),
"query": query,
"source": SOURCE_NAME,
"results": [self._normalize_author_entry(r) for r in records[:rows]],
}
return self._run("Scopus author search", run)
def get_author(
self,
author_id: str,
view: str = "ENHANCED",
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve a Scopus author profile."""
if not author_id or not author_id.strip():
raise DataSourceError(SOURCE_NAME, "Empty author ID")
def run() -> dict[str, Any]:
author = AuthorRetrieval(author_id.strip(), view=view, refresh=refresh)
return {
"author_id": safe_attr(author, "identifier"),
"eid": safe_attr(author, "eid"),
"orcid": safe_attr(author, "orcid"),
"surname": safe_attr(author, "surname"),
"given_name": safe_attr(author, "given_name"),
"indexed_name": safe_attr(author, "indexed_name"),
"document_count": safe_attr(author, "document_count"),
"citation_count": safe_attr(author, "citation_count"),
"cited_by_count": safe_attr(author, "cited_by_count"),
"h_index": safe_attr(author, "h_index"),
"coauthor_count": safe_attr(author, "coauthor_count"),
"publication_range": record_to_dict(safe_attr(author, "publication_range")),
"affiliation_current": record_to_dict(safe_attr(author, "affiliation_current")),
"affiliation_history": record_to_dict(safe_attr(author, "affiliation_history")),
"subject_areas": record_to_dict(safe_attr(author, "subject_areas")),
"scopus_author_link": safe_attr(author, "scopus_author_link"),
"url": safe_attr(author, "url"),
"source": SOURCE_NAME,
}
return self._run("Scopus author retrieval", run)
def search_affiliations(
self,
query: str,
rows: int = 5,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Search Scopus affiliations and return only the requested first page."""
if not query or not query.strip():
raise DataSourceError(SOURCE_NAME, "Empty affiliation search query")
rows = max(1, min(rows, 50))
def run() -> dict[str, Any]:
data = self._search_api(
"AffiliationSearch",
{"query": query.strip(), "count": rows, "start": 0},
)
records = _search_entries(data)
return {
"total": _total_results(data),
"query": query,
"source": SOURCE_NAME,
"results": [self._normalize_affiliation_entry(r) for r in records[:rows]],
}
return self._run("Scopus affiliation search", run)
def get_affiliation(
self,
affiliation_id: str,
view: str = "STANDARD",
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve a Scopus affiliation profile."""
if not affiliation_id or not affiliation_id.strip():
raise DataSourceError(SOURCE_NAME, "Empty affiliation ID")
def run() -> dict[str, Any]:
affiliation = AffiliationRetrieval(
affiliation_id.strip(),
view=view,
refresh=refresh,
)
return {
"affiliation_id": safe_attr(affiliation, "identifier"),
"eid": safe_attr(affiliation, "eid"),
"name": safe_attr(affiliation, "affiliation_name"),
"sort_name": safe_attr(affiliation, "sort_name"),
"documents": safe_attr(affiliation, "document_count"),
"authors": safe_attr(affiliation, "author_count"),
"address": safe_attr(affiliation, "address"),
"city": safe_attr(affiliation, "city"),
"state": safe_attr(affiliation, "state"),
"country": safe_attr(affiliation, "country"),
"postal_code": safe_attr(affiliation, "postal_code"),
"org_domain": safe_attr(affiliation, "org_domain"),
"org_url": safe_attr(affiliation, "org_URL"),
"variants": record_to_dict(safe_attr(affiliation, "name_variants")),
"scopus_affiliation_link": safe_attr(affiliation, "scopus_affiliation_link"),
"url": safe_attr(affiliation, "url"),
"source": SOURCE_NAME,
}
return self._run("Scopus affiliation retrieval", run)
def search_serial_titles(
self,
query: dict[str, str],
rows: int = 5,
view: str = "ENHANCED",
refresh: bool | int = False,
) -> dict[str, Any]:
"""Search Scopus serial titles and return only the requested first page."""
clean = {k: v for k, v in query.items() if v}
if not clean:
raise DataSourceError(SOURCE_NAME, "Empty serial title query")
rows = max(1, min(rows, 50))
def run() -> dict[str, Any]:
data = self._search_api(
"SerialTitleSearch",
{**clean, "count": rows, "start": 0, "view": view},
)
records = data.get("serial-metadata-response", {}).get("entry", [])
records = _as_list(records)
return {
"total": len(records),
"query": clean,
"source": SOURCE_NAME,
"results": [record_to_dict(r) for r in records[:rows]],
}
return self._run("Scopus serial title search", run)
def get_serial_title(
self,
issn: str,
view: str = "ENHANCED",
years: str | None = None,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve a Scopus serial title by ISSN."""
if not issn or not issn.strip():
raise DataSourceError(SOURCE_NAME, "Empty ISSN")
def run() -> dict[str, Any]:
title = SerialTitleISSN(
issn.strip(),
view=view,
years=years,
refresh=refresh,
)
return {
"title": safe_attr(title, "title"),
"source_id": safe_attr(title, "source_id"),
"issn": safe_attr(title, "issn"),
"eissn": safe_attr(title, "eissn"),
"publisher": safe_attr(title, "publisher"),
"aggregation_type": safe_attr(title, "aggregation_type"),
"openaccess": safe_attr(title, "openaccess"),
"subject_areas": record_to_dict(safe_attr(title, "subject_area")),
"citescore_year_info": record_to_dict(
safe_attr(title, "citescoreyearinfolist")
),
"scopus_source_link": safe_attr(title, "scopus_source_link"),
"source": SOURCE_NAME,
}
return self._run("Scopus serial title retrieval", run)
def get_plumx_metrics(
self,
identifier: str,
id_type: str,
refresh: bool | int = False,
) -> dict[str, Any]:
"""Retrieve PlumX metrics for a document."""
if not identifier or not identifier.strip():
raise DataSourceError(SOURCE_NAME, "Empty PlumX identifier")
if not id_type or not id_type.strip():
raise DataSourceError(SOURCE_NAME, "Empty PlumX id_type")
def run() -> dict[str, Any]:
metrics = PlumXMetrics(
identifier.strip(),
id_type.strip(),
refresh=refresh,
)
return {
"identifier": identifier,
"id_type": id_type,
"category_totals": record_to_dict(safe_attr(metrics, "category_totals")),
"capture": record_to_dict(safe_attr(metrics, "capture")),
"citation": record_to_dict(safe_attr(metrics, "citation")),
"mention": record_to_dict(safe_attr(metrics, "mention")),
"social_media": record_to_dict(safe_attr(metrics, "social_media")),
"usage": record_to_dict(safe_attr(metrics, "usage")),
"source": SOURCE_NAME,
}
return self._run("Scopus PlumX metrics", run)
def _run(self, operation: str, callback):
ensure_pybliometrics_config(SOURCE_NAME)
try:
return callback()
except DataSourceError:
raise
except Exception as exc:
raise DataSourceError(
SOURCE_NAME,
f"{operation} failed: {exc}",
original_error=exc,
) from exc
@staticmethod
def _search_api(api: str, params: dict[str, Any]) -> dict[str, Any]:
response = get_content(URLS[api], api, params=params)
return response.json()
@staticmethod
def _normalize_search_entry(data: dict[str, Any]) -> dict[str, Any]:
return {
"title": data.get("dc:title"),
"authors": _extract_authors(data),
"year": year_from_date(data.get("prism:coverDate")),
"doi": data.get("prism:doi"),
"eid": data.get("eid"),
"pii": data.get("pii"),
"pmid": data.get("pubmed-id"),
"journal": data.get("prism:publicationName"),
"volume": data.get("prism:volume"),
"issue": data.get("prism:issueIdentifier"),
"pages": data.get("prism:pageRange"),
"subtype": data.get("subtype"),
"subtype_description": data.get("subtypeDescription"),
"citation_count": _int_or_none(data.get("citedby-count")),
"openaccess": data.get("openaccess"),
"source": SOURCE_NAME,
}
@staticmethod
def _normalize_author_entry(data: dict[str, Any]) -> dict[str, Any]:
preferred = data.get("preferred-name", {})
affiliation = data.get("affiliation-current", {})
areas = _as_list(data.get("subject-area"))
return {
"eid": data.get("eid"),
"orcid": data.get("orcid"),
"surname": preferred.get("surname"),
"initials": preferred.get("initials"),
"givenname": preferred.get("given-name"),
"affiliation": affiliation.get("affiliation-name"),
"documents": _int_or_none(data.get("document-count")),
"affiliation_id": affiliation.get("affiliation-id"),
"city": affiliation.get("affiliation-city"),
"country": affiliation.get("affiliation-country"),
"areas": [
{
"abbreviation": area.get("@abbrev"),
"frequency": _int_or_none(area.get("@frequency")),
"name": area.get("$"),
}
for area in areas
],
"source": SOURCE_NAME,
}
@staticmethod
def _normalize_affiliation_entry(data: dict[str, Any]) -> dict[str, Any]:
variants = [
item.get("$")
for item in _as_list(data.get("name-variant"))
if item.get("$") and item.get("$") != data.get("affiliation-name")
]
return {
"eid": data.get("eid"),
"name": data.get("affiliation-name"),
"variant": ";".join(variants),
"documents": _int_or_none(data.get("document-count")),
"city": data.get("city"),
"country": data.get("country"),
"source": SOURCE_NAME,
}
@staticmethod
def _normalize_abstract(paper: Any) -> dict[str, Any]:
return {
"title": safe_attr(paper, "title"),
"authors": record_to_dict(safe_attr(paper, "authors")),
"year": year_from_date(safe_attr(paper, "coverDate")),
"doi": safe_attr(paper, "doi"),
"eid": safe_attr(paper, "eid"),
"scopus_id": safe_attr(paper, "identifier"),
"pii": safe_attr(paper, "pii"),
"abstract": safe_attr(paper, "abstract") or safe_attr(paper, "description"),
"journal": safe_attr(paper, "publicationName"),
"volume": safe_attr(paper, "volume"),
"issue": safe_attr(paper, "issueIdentifier"),
"pages": safe_attr(paper, "pageRange"),
"publisher": safe_attr(paper, "publisher"),
"citation_count": safe_attr(paper, "citedby_count"),
"reference_count": safe_attr(paper, "refcount"),
"affiliations": record_to_dict(safe_attr(paper, "affiliation")),
"author_keywords": safe_attr(paper, "authkeywords"),
"subject_areas": record_to_dict(safe_attr(paper, "subject_areas")),
"url": safe_attr(paper, "url"),
"source": SOURCE_NAME,
}
@staticmethod
def _normalize_citation_overview(overview: Any) -> dict[str, Any]:
titles = safe_attr(overview, "title") or []
documents = []
parallel_fields = {
"scopus_id": safe_attr(overview, "scopus_id"),
"doi": safe_attr(overview, "doi"),
"title": titles,
"publication_name": safe_attr(overview, "publicationName"),
"row_total": safe_attr(overview, "rowTotal"),
"range_count": safe_attr(overview, "rangeCount"),
"citation_type": safe_attr(overview, "citationType_long"),
"yearly_citations": safe_attr(overview, "cc"),
}
for idx in range(len(titles)):
doc = {}
for key, values in parallel_fields.items():
if values is not None and idx < len(values):
doc[key] = record_to_dict(values[idx])
documents.append(doc)
return {
"grand_total": safe_attr(overview, "grandTotal"),
"h_index": safe_attr(overview, "h_index"),
"column_total": safe_attr(overview, "columnTotal"),
"previous_column_total": safe_attr(overview, "prevColumnTotal"),
"range_column_total": safe_attr(overview, "rangeColumnTotal"),
"later_column_total": safe_attr(overview, "laterColumnTotal"),
"documents": documents,
"source": SOURCE_NAME,
}
def _search_entries(data: dict[str, Any]) -> list[dict[str, Any]]:
entries = data.get("search-results", {}).get("entry", [])
return _as_list(entries)
def _total_results(data: dict[str, Any]) -> int:
total = data.get("search-results", {}).get("opensearch:totalResults", 0)
return _int_or_none(total) or 0
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _extract_authors(data: dict[str, Any]) -> list[str]:
authors = []
for item in _as_list(data.get("author")):
name = item.get("authname") or item.get("ce:indexed-name")
if name:
authors.append(name)
creator = data.get("dc:creator")
if not authors and creator:
authors.append(creator)
return authors
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
"""Tests for academic search server."""
"""Live API tests for Scopus and ScienceDirect.
These tests intentionally call Elsevier APIs through the local pybliometrics
configuration. They are not mocked.
"""
from __future__ import annotations
import asyncio
import json
import os
import pytest
from sources import ScienceDirectSource, ScopusSource
pytestmark = pytest.mark.skipif(
os.getenv("NATURE_ACADEMIC_SEARCH_LIVE_ELSEVIER") != "1",
reason="set NATURE_ACADEMIC_SEARCH_LIVE_ELSEVIER=1 to run live Elsevier API tests",
)
ELSEVIER_DOI = "10.1016/j.istruc.2024.107944"
ELSEVIER_TITLE_QUERY = (
"Seismic performance continuous rigid-frame bridges flood-induced scour"
)
def _call_tool_json(tool_name: str, arguments: dict) -> dict:
from academic_search_server import mcp
content, _metadata = asyncio.run(mcp.call_tool(tool_name, arguments))
return json.loads(content[0].text)
def test_scopus_live_search_exact_doi():
result = ScopusSource().search(f"DOI({ELSEVIER_DOI})", rows=1)
assert result["source"] == "scopus"
assert result["total"] >= 1
assert len(result["results"]) == 1
assert result["results"][0]["doi"] == ELSEVIER_DOI
def test_sciencedirect_live_search_title_query():
result = ScienceDirectSource().search(ELSEVIER_TITLE_QUERY, rows=1)
assert result["source"] == "sciencedirect"
assert result["total"] >= 1
assert len(result["results"]) == 1
assert result["results"][0]["doi"] == ELSEVIER_DOI
def test_sciencedirect_live_article_metadata_doi_query():
result = ScienceDirectSource().get_article_metadata(
f"doi({ELSEVIER_DOI})",
rows=1,
)
assert result["source"] == "sciencedirect"
assert result["total"] >= 1
assert len(result["results"]) == 1
assert result["results"][0]["doi"] == ELSEVIER_DOI
def test_default_search_papers_uses_free_sources_only():
payload = _call_tool_json(
"search_papers",
{"query": ELSEVIER_TITLE_QUERY, "rows": 1},
)
assert payload["sources_queried"] == ["crossref", "pubmed", "arxiv"]
assert "scopus" not in payload["sources_queried"]
assert "sciencedirect" not in payload["sources_queried"]
def test_explicit_search_papers_includes_elsevier_sources():
payload = _call_tool_json(
"search_papers",
{
"query": ELSEVIER_TITLE_QUERY,
"sources": ["scopus", "sciencedirect"],
"rows": 1,
},
)
assert payload["sources_queried"] == ["scopus", "sciencedirect"]
assert any(item["source"] == "scopus" for item in payload["results"])
assert any(item["source"] == "sciencedirect" for item in payload["results"])
"""MCP dispatch tests for academic_search_server tools."""
from __future__ import annotations
import asyncio
import json
def _call_tool_json(tool_name: str, arguments: dict) -> dict:
from academic_search_server import mcp
content, _metadata = asyncio.run(mcp.call_tool(tool_name, arguments))
return json.loads(content[0].text)
def test_search_papers_mcp_dispatch_uses_default_sources(monkeypatch):
import academic_search_server
captured = {}
async def fake_search_all(query, sources, rows, filter_type):
captured.update({
"query": query,
"sources": list(sources),
"rows": rows,
"filter_type": filter_type,
})
return {
"total": 0,
"sources_queried": list(sources),
"result_count": 0,
"results": [],
"errors": None,
}
monkeypatch.setattr(academic_search_server, "_search_all", fake_search_all)
payload = _call_tool_json("search_papers", {"query": "graphene", "rows": 100})
assert payload["sources_queried"] == ["crossref", "pubmed", "arxiv"]
assert "error" not in payload
assert captured == {
"query": "graphene",
"sources": ["crossref", "pubmed", "arxiv"],
"rows": 50,
"filter_type": None,
}
def test_search_papers_mcp_dispatch_accepts_elsevier_sources(monkeypatch):
import academic_search_server
captured = {}
async def fake_search_all(query, sources, rows, filter_type):
captured.update({
"query": query,
"sources": list(sources),
"rows": rows,
"filter_type": filter_type,
})
return {
"total": 2,
"sources_queried": list(sources),
"result_count": 2,
"results": [
{"source": "scopus", "title": "Scopus result"},
{"source": "sciencedirect", "title": "ScienceDirect result"},
],
"errors": None,
}
monkeypatch.setattr(academic_search_server, "_search_all", fake_search_all)
payload = _call_tool_json(
"search_papers",
{
"query": "bridge scour",
"sources": ["scopus", "sciencedirect"],
"rows": 1,
"type": "journal-article",
},
)
assert payload["sources_queried"] == ["scopus", "sciencedirect"]
assert {item["source"] for item in payload["results"]} == {
"scopus",
"sciencedirect",
}
assert captured == {
"query": "bridge scour",
"sources": ["scopus", "sciencedirect"],
"rows": 1,
"filter_type": "journal-article",
}
"""Unit tests for academic search source modules and ID detection.
All external HTTP calls are mocked -- no network access required.
"""
from __future__ import annotations
import json
import re
import xml.etree.ElementTree as ET
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
def _make_crossref_config():
"""Return a mock Config object for CrossRef."""
cfg = MagicMock()
cfg.crossref_mailto = "test@example.com"
cfg.crossref_timeout = 10
return cfg
def _make_pubmed_config():
"""Return a mock Config object for PubMed."""
cfg = MagicMock()
cfg.pubmed_email = "test@example.com"
cfg.pubmed_api_key = ""
cfg.max_rows = 50
return cfg
def _make_arxiv_config():
"""Return a mock Config object for arXiv."""
cfg = MagicMock()
cfg.arxiv_timeout = 10
return cfg
# ===================================================================
# 1. CrossRef tests
# ===================================================================
class TestCrossRefSearch:
"""Test CrossRef search returns the unified result format."""
@patch("sources.crossref.get_config")
@patch("sources.crossref.requests.get")
def test_search_returns_unified_format(self, mock_get, mock_config):
mock_config.return_value = _make_crossref_config()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"message": {
"total-results": 42,
"items": [
{
"title": ["Deep Learning for NLP"],
"author": [
{"given": "Alice", "family": "Smith"},
{"given": "Bob", "family": "Jones"},
],
"published-print": {"date-parts": [[2023, 6, 15]]},
"DOI": "10.1234/example.2023",
"container-title": ["Journal of AI Research"],
"is-referenced-by-count": 17,
}
],
}
}
mock_get.return_value = mock_resp
from sources.crossref import CrossRefSource
source = CrossRefSource()
result = source.search("deep learning", rows=5)
assert "total" in result
assert "results" in result
assert result["total"] == 42
assert len(result["results"]) == 1
item = result["results"][0]
assert item["title"] == "Deep Learning for NLP"
assert item["authors"] == ["Alice Smith", "Bob Jones"]
assert item["year"] == 2023
assert item["doi"] == "10.1234/example.2023"
assert item["journal"] == "Journal of AI Research"
assert item["source"] == "crossref"
assert item["citation_count"] == 17
@patch("sources.crossref.get_config")
@patch("sources.crossref.requests.get")
def test_search_with_type_filter(self, mock_get, mock_config):
mock_config.return_value = _make_crossref_config()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"message": {"total-results": 0, "items": []}}
mock_get.return_value = mock_resp
from sources.crossref import CrossRefSource
source = CrossRefSource()
result = source.search("test", rows=5, filter_type="journal-article")
called_params = mock_get.call_args[1]["params"]
assert called_params["filter"] == "type:journal-article"
assert result["total"] == 0
assert result["results"] == []
@patch("sources.crossref.get_config")
@patch("sources.crossref.requests.get")
def test_search_empty_items(self, mock_get, mock_config):
mock_config.return_value = _make_crossref_config()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"message": {"total-results": 0, "items": []}
}
mock_get.return_value = mock_resp
from sources.crossref import CrossRefSource
source = CrossRefSource()
result = source.search("nonexistent query xyz")
assert result["total"] == 0
assert result["results"] == []
@patch("sources.crossref.get_config")
@patch("sources.crossref.requests.get")
def test_get_by_doi(self, mock_get, mock_config):
mock_config.return_value = _make_crossref_config()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"message": {
"title": ["A Great Paper"],
"author": [{"given": "Jane", "family": "Doe"}],
"published-online": {"date-parts": [[2024]]},
"DOI": "10.1038/nature12373",
"container-title": ["Nature"],
"abstract": "<p>We discovered something.</p>",
"volume": "615",
"issue": "7951",
"page": "100-105",
"publisher": "Springer Nature",
"type": "journal-article",
"references-count": 42,
"URL": "https://doi.org/10.1038/nature12373",
}
}
mock_get.return_value = mock_resp
from sources.crossref import CrossRefSource
source = CrossRefSource()
detail = source.get_by_doi("10.1038/nature12373")
assert detail["title"] == "A Great Paper"
assert detail["doi"] == "10.1038/nature12373"
assert detail["abstract"] == "<p>We discovered something.</p>"
assert detail["volume"] == "615"
assert detail["type"] == "journal-article"
assert detail["source"] == "crossref"
@patch("sources.crossref.get_config")
@patch("sources.crossref.requests.get")
def test_search_http_error(self, mock_get, mock_config):
import requests as real_requests
mock_config.return_value = _make_crossref_config()
mock_resp = MagicMock()
mock_resp.status_code = 503
mock_resp.raise_for_status.side_effect = real_requests.HTTPError(
response=mock_resp
)
mock_get.return_value = mock_resp
from sources.crossref import CrossRefSource
from utils.errors import DataSourceError
source = CrossRefSource()
with pytest.raises(DataSourceError, match="crossref"):
source.search("test")
@patch("sources.crossref.get_config")
@patch("sources.crossref.requests.get")
def test_get_citation(self, mock_get, mock_config):
mock_config.return_value = _make_crossref_config()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.text = "Smith, A. (2023). Deep Learning. Nature."
mock_get.return_value = mock_resp
from sources.crossref import CrossRefSource
source = CrossRefSource()
citation = source.get_citation("10.1038/nature12373", style="nature")
assert "Smith" in citation
assert "2023" in citation
# ===================================================================
# 2. PubMed tests
# ===================================================================
class TestPubMedSearch:
"""Test PubMed esearch + efetch flow with WebEnv/query_key handling."""
@patch("sources.pubmed.get_config")
@patch("sources.pubmed._get")
def test_search_esearch_efetch_flow(self, mock_get, mock_config):
mock_config.return_value = _make_pubmed_config()
# esearch response
esearch_xml = """<?xml version="1.0"?>
<eSearchResult>
<Count>1</Count>
<RetMax>1</RetMax>
<WebEnv>ABC123_webenv</WebEnv>
<QueryKey>1</QueryKey>
<IdList><Id>99999999</Id></IdList>
</eSearchResult>"""
# efetch response
efetch_xml = """<?xml version="1.0"?>
<PubmedArticleSet>
<PubmedArticle>
<MedlineCitation>
<PMID>99999999</PMID>
<Article>
<ArticleTitle>Genomic Analysis of Cancer</ArticleTitle>
<AuthorList>
<Author>
<LastName>Wang</LastName>
<ForeName>Li</ForeName>
</Author>
<Author>
<LastName>Zhang</LastName>
<ForeName>Wei</ForeName>
</Author>
</AuthorList>
<Abstract>
<AbstractText>We performed whole-genome sequencing.</AbstractText>
</Abstract>
<Journal>
<Title>Nature Medicine</Title>
<JournalIssue>
<PubDate><Year>2024</Year></PubDate>
</JournalIssue>
</Journal>
<ELocationID EIdType="doi">10.1038/s41591-024-00001</ELocationID>
</Article>
</MedlineCitation>
</PubmedArticle>
</PubmedArticleSet>"""
esearch_resp = MagicMock()
esearch_resp.content = esearch_xml.encode("utf-8")
efetch_resp = MagicMock()
efetch_resp.content = efetch_xml.encode("utf-8")
mock_get.side_effect = [esearch_resp, efetch_resp]
from sources.pubmed import PubMedSource
source = PubMedSource()
result = source.search("cancer genomics", rows=5)
assert result["total"] == 1
assert result["query"] == "cancer genomics"
assert len(result["results"]) == 1
item = result["results"][0]
assert item["title"] == "Genomic Analysis of Cancer"
assert item["authors"] == ["Wang Li", "Zhang Wei"]
assert item["year"] == 2024
assert item["pmid"] == "99999999"
assert item["doi"] == "10.1038/s41591-024-00001"
assert item["journal"] == "Nature Medicine"
assert item["source"] == "pubmed"
assert "whole-genome" in item["abstract"]
# Verify WebEnv and query_key were passed to efetch
# _get(endpoint, params) -- params is positional arg index 1
efetch_call_args = mock_get.call_args_list[1]
efetch_params = efetch_call_args[0][1]
assert efetch_params["WebEnv"] == "ABC123_webenv"
assert efetch_params["query_key"] == "1"
@patch("sources.pubmed.get_config")
@patch("sources.pubmed._get")
def test_search_no_results(self, mock_get, mock_config):
mock_config.return_value = _make_pubmed_config()
esearch_xml = """<?xml version="1.0"?>
<eSearchResult>
<Count>0</Count>
<RetMax>0</RetMax>
<IdList></IdList>
</eSearchResult>"""
mock_resp = MagicMock()
mock_resp.content = esearch_xml.encode("utf-8")
mock_get.return_value = mock_resp
from sources.pubmed import PubMedSource
source = PubMedSource()
result = source.search("xyznonexistent12345", rows=5)
assert result["total"] == 0
assert result["results"] == []
@patch("sources.pubmed.get_config")
@patch("sources.pubmed._get")
def test_get_by_pmid(self, mock_get, mock_config):
mock_config.return_value = _make_pubmed_config()
efetch_xml = """<?xml version="1.0"?>
<PubmedArticleSet>
<PubmedArticle>
<MedlineCitation>
<PMID>12345678</PMID>
<Article>
<ArticleTitle>CRISPR Gene Editing Review</ArticleTitle>
<AuthorList>
<Author>
<LastName>Chen</LastName>
<ForeName>Xiaoming</ForeName>
</Author>
</AuthorList>
<Abstract>
<AbstractText>A comprehensive review of CRISPR.</AbstractText>
</Abstract>
<Journal>
<Title>Cell</Title>
<JournalIssue>
<PubDate><Year>2023</Year></PubDate>
</JournalIssue>
</Journal>
</Article>
</MedlineCitation>
</PubmedArticle>
</PubmedArticleSet>"""
mock_resp = MagicMock()
mock_resp.content = efetch_xml.encode("utf-8")
mock_get.return_value = mock_resp
from sources.pubmed import PubMedSource
source = PubMedSource()
result = source.get_by_pmid("12345678")
assert result["title"] == "CRISPR Gene Editing Review"
assert result["pmid"] == "12345678"
assert result["journal"] == "Cell"
assert result["source"] == "pubmed"
@patch("sources.pubmed.get_config")
def test_search_empty_query_raises(self, mock_config):
mock_config.return_value = _make_pubmed_config()
from sources.pubmed import PubMedSource
from utils.errors import DataSourceError
source = PubMedSource()
with pytest.raises(DataSourceError, match="Empty"):
source.search("")
@patch("sources.pubmed.get_config")
def test_search_no_email_raises(self, mock_config):
cfg = MagicMock()
cfg.pubmed_email = ""
mock_config.return_value = cfg
from sources.pubmed import PubMedSource
from utils.errors import DataSourceError
source = PubMedSource()
with pytest.raises(DataSourceError, match="email"):
source.search("test query")
@patch("sources.pubmed.get_config")
@patch("sources.pubmed._get")
def test_search_multiple_articles(self, mock_get, mock_config):
mock_config.return_value = _make_pubmed_config()
esearch_xml = """<?xml version="1.0"?>
<eSearchResult>
<Count>2</Count>
<RetMax>2</RetMax>
<WebEnv>ENV456</WebEnv>
<QueryKey>2</QueryKey>
<IdList><Id>111</Id><Id>222</Id></IdList>
</eSearchResult>"""
efetch_xml = """<?xml version="1.0"?>
<PubmedArticleSet>
<PubmedArticle>
<MedlineCitation>
<PMID>111</PMID>
<Article>
<ArticleTitle>First Paper</ArticleTitle>
<AuthorList>
<Author><LastName>A</LastName><ForeName>B</ForeName></Author>
</AuthorList>
<Journal><Title>J1</Title><JournalIssue><PubDate><Year>2022</Year></PubDate></JournalIssue></Journal>
</Article>
</MedlineCitation>
</PubmedArticle>
<PubmedArticle>
<MedlineCitation>
<PMID>222</PMID>
<Article>
<ArticleTitle>Second Paper</ArticleTitle>
<AuthorList>
<Author><LastName>C</LastName><ForeName>D</ForeName></Author>
</AuthorList>
<Journal><Title>J2</Title><JournalIssue><PubDate><Year>2023</Year></PubDate></JournalIssue></Journal>
</Article>
</MedlineCitation>
</PubmedArticle>
</PubmedArticleSet>"""
esearch_resp = MagicMock()
esearch_resp.content = esearch_xml.encode("utf-8")
efetch_resp = MagicMock()
efetch_resp.content = efetch_xml.encode("utf-8")
mock_get.side_effect = [esearch_resp, efetch_resp]
from sources.pubmed import PubMedSource
source = PubMedSource()
result = source.search("multi test", rows=2)
assert result["total"] == 2
assert len(result["results"]) == 2
assert result["results"][0]["pmid"] == "111"
assert result["results"][1]["pmid"] == "222"
# ===================================================================
# 3. arXiv tests
# ===================================================================
_ARXIV_ATOM_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:arxiv="http://arxiv.org/schemas/atom">
<title>ArXiv Query: search_query={query}</title>
{entries}
</feed>"""
_ARXIV_ENTRY_TEMPLATE = """<entry>
<id>http://arxiv.org/abs/{arxiv_id}v1</id>
<title>{title}</title>
<summary>{summary}</summary>
{author_elements}
<published>{published}</published>
<arxiv:primary_category term="{category}" xmlns:arxiv="http://arxiv.org/schemas/atom"/>
<link rel="alternate" type="text/html" href="http://arxiv.org/abs/{arxiv_id}v1"/>
<link title="pdf" rel="related" type="application/pdf" href="http://arxiv.org/pdf/{arxiv_id}v1"/>
</entry>"""
def _build_arxiv_xml(query: str, entries_data: list[dict]) -> str:
"""Build a minimal arXiv Atom XML response."""
entries_xml = []
for e in entries_data:
author_elements = "".join(
f"<author><name>{a}</name></author>" for a in e.get("authors", [])
)
entries_xml.append(
_ARXIV_ENTRY_TEMPLATE.format(
arxiv_id=e.get("arxiv_id", "2401.00001"),
title=e.get("title", "Untitled"),
summary=e.get("summary", ""),
author_elements=author_elements,
published=e.get("published", "2024-01-01T00:00:00Z"),
category=e.get("category", "cs.AI"),
)
)
return _ARXIV_ATOM_TEMPLATE.format(
query=query, entries="\n".join(entries_xml)
)
class TestArxivSearch:
"""Test arXiv search with date filtering and ID normalization."""
@patch("sources.arxiv.get_config")
@patch.object(
__import__("sources.arxiv", fromlist=["ArxivSource"]).ArxivSource,
"_request",
)
def test_search_returns_unified_format(self, mock_request, mock_config):
mock_config.return_value = _make_arxiv_config()
xml = _build_arxiv_xml("transformer", [
{
"arxiv_id": "2401.12345",
"title": "Attention Is All You Need Again",
"summary": "We revisit the transformer architecture.",
"authors": ["Alice Smith", "Bob Jones"],
"published": "2024-01-22T00:00:00Z",
"category": "cs.CL",
}
])
mock_request.return_value = xml
from sources.arxiv import ArxivSource
source = ArxivSource()
result = source.search("transformer", rows=5)
assert "total" in result
assert "results" in result
assert result["total"] == 1
assert result["source"] == "arxiv"
item = result["results"][0]
assert item["title"] == "Attention Is All You Need Again"
assert item["authors"] == ["Alice Smith", "Bob Jones"]
assert item["year"] == 2024
assert item["arxiv_id"] == "2401.12345"
assert item["categories"] == ["cs.CL"]
assert item["source"] == "arxiv"
@patch("sources.arxiv.get_config")
@patch.object(
__import__("sources.arxiv", fromlist=["ArxivSource"]).ArxivSource,
"_request",
)
def test_search_with_date_filter(self, mock_request, mock_config):
mock_config.return_value = _make_arxiv_config()
xml = _build_arxiv_xml("LLM", [])
mock_request.return_value = xml
from sources.arxiv import ArxivSource
source = ArxivSource()
source.search("LLM", rows=5, date_from="2024-01-01", date_to="2024-06-30")
called_params = mock_request.call_args[0][0]
search_query = called_params["search_query"]
assert "submittedDate:[" in search_query
assert "202401010000+TO+202406302359" in search_query
@patch("sources.arxiv.get_config")
@patch.object(
__import__("sources.arxiv", fromlist=["ArxivSource"]).ArxivSource,
"_request",
)
def test_search_with_categories(self, mock_request, mock_config):
mock_config.return_value = _make_arxiv_config()
xml = _build_arxiv_xml("robotics", [])
mock_request.return_value = xml
from sources.arxiv import ArxivSource
source = ArxivSource()
source.search("robotics", rows=5, categories=["cs.RO", "cs.AI"])
called_params = mock_request.call_args[0][0]
search_query = called_params["search_query"]
assert "cat:cs.RO" in search_query
assert "cat:cs.AI" in search_query
@patch("sources.arxiv.get_config")
@patch.object(
__import__("sources.arxiv", fromlist=["ArxivSource"]).ArxivSource,
"_request",
)
def test_get_by_id(self, mock_request, mock_config):
mock_config.return_value = _make_arxiv_config()
xml = _build_arxiv_xml("id", [
{
"arxiv_id": "2301.00001",
"title": "Foundational LLM Paper",
"summary": "We introduce a new LLM.",
"authors": ["Researcher One"],
"published": "2023-01-01T00:00:00Z",
"category": "cs.AI",
}
])
mock_request.return_value = xml
from sources.arxiv import ArxivSource
source = ArxivSource()
result = source.get_by_id("2301.00001")
assert result["title"] == "Foundational LLM Paper"
assert result["arxiv_id"] == "2301.00001"
assert result["source"] == "arxiv"
@patch("sources.arxiv.get_config")
@patch.object(
__import__("sources.arxiv", fromlist=["ArxivSource"]).ArxivSource,
"_request",
)
def test_get_by_id_not_found(self, mock_request, mock_config):
mock_config.return_value = _make_arxiv_config()
# Empty feed = no results
xml = _build_arxiv_xml("id", [])
mock_request.return_value = xml
from sources.arxiv import ArxivSource
from utils.errors import DataSourceError
source = ArxivSource()
with pytest.raises(DataSourceError, match="not found"):
source.get_by_id("9999.99999")
def test_normalize_id_strips_url_prefix_and_version(self):
from sources.arxiv import ArxivSource
source = ArxivSource.__new__(ArxivSource)
assert source._normalize_id("http://arxiv.org/abs/2401.12345v1") == "2401.12345"
assert source._normalize_id("https://arxiv.org/abs/2401.12345v2") == "2401.12345"
assert source._normalize_id("2401.12345") == "2401.12345"
assert source._normalize_id("2401.12345v3") == "2401.12345"
def test_build_date_filter_syntax(self):
from sources.arxiv import ArxivSource
result = ArxivSource._build_date_filter("2024-01-01", "2024-12-31")
assert result == "submittedDate:[202401010000+TO+202412312359]"
def test_build_date_filter_only_from(self):
from sources.arxiv import ArxivSource
result = ArxivSource._build_date_filter("2024-06-01", None)
assert "submittedDate:[" in result
assert "202406010000" in result
assert "999912312359" in result
def test_build_date_filter_empty(self):
from sources.arxiv import ArxivSource
assert ArxivSource._build_date_filter(None, None) == ""
# ===================================================================
# 4. ID auto-detection tests
# ===================================================================
class TestDetectIdType:
"""Test _detect_id_type auto-identification logic."""
def test_detect_doi(self):
from academic_search_server import _detect_id_type
assert _detect_id_type("10.1038/nature12373") == "doi"
assert _detect_id_type("10.1126/science.abc1234") == "doi"
assert _detect_id_type("10.1016/j.cell.2023.01.001") == "doi"
def test_detect_pmid(self):
from academic_search_server import _detect_id_type
assert _detect_id_type("12345678") == "pmid"
assert _detect_id_type("1234567") == "pmid"
def test_detect_arxiv(self):
from academic_search_server import _detect_id_type
assert _detect_id_type("2401.12345") == "arxiv"
assert _detect_id_type("2301.00001") == "arxiv"
assert _detect_id_type("2401.12345v1") == "arxiv"
def test_detect_doi_with_whitespace(self):
from academic_search_server import _detect_id_type
assert _detect_id_type(" 10.1038/nature12373 ") == "doi"
def test_detect_unknown_raises(self):
from academic_search_server import _detect_id_type
with pytest.raises(ValueError, match="Cannot detect"):
_detect_id_type("abc123")
def test_detect_short_number_raises(self):
"""6-digit number is too short for PMID (needs 7-8)."""
from academic_search_server import _detect_id_type
with pytest.raises(ValueError, match="Cannot detect"):
_detect_id_type("123456")
class TestResolveIdType:
"""Test _resolve_id_type explicit and auto modes."""
def test_explicit_doi(self):
from academic_search_server import _resolve_id_type
assert _resolve_id_type("10.1038/test", "doi") == "doi"
def test_explicit_pmid(self):
from academic_search_server import _resolve_id_type
assert _resolve_id_type("12345678", "pmid") == "pmid"
def test_auto_delegates(self):
from academic_search_server import _resolve_id_type
assert _resolve_id_type("10.1038/test", "auto") == "doi"
assert _resolve_id_type("12345678", "auto") == "pmid"
assert _resolve_id_type("2401.12345", "auto") == "arxiv"
def test_invalid_type_raises(self):
from academic_search_server import _resolve_id_type
with pytest.raises(ValueError, match="Unsupported"):
_resolve_id_type("anything", "invalid_type")
"""Utility modules for academic search."""
from .config import Config, get_config
from .errors import AcademicSearchError, ConfigError, DataSourceError, TimeoutError
from .logging import setup_logging
__all__ = [
"AcademicSearchError",
"DataSourceError",
"TimeoutError",
"ConfigError",
"setup_logging",
"get_config",
"Config",
]
"""Configuration management for academic search server."""
import os
from pathlib import Path
import toml
class Config:
def __init__(self, config_path: str | Path | None = None):
if config_path is None:
config_path = Path(__file__).parent.parent / "config.toml"
self._config = toml.load(config_path)
@property
def pubmed_email(self) -> str:
return os.environ.get("PUBMED_EMAIL") or self._config.get("pubmed", {}).get("email", "")
@property
def pubmed_api_key(self) -> str:
return os.environ.get("NCBI_API_KEY") or self._config.get("pubmed", {}).get("api_key", "")
@property
def crossref_mailto(self) -> str:
return self._config.get("crossref", {}).get("mailto", "")
@property
def crossref_timeout(self) -> int:
return self._config.get("crossref", {}).get("timeout", 15)
@property
def arxiv_timeout(self) -> int:
return self._config.get("arxiv", {}).get("timeout", 30)
@property
def default_rows(self) -> int:
return self._config.get("general", {}).get("default_rows", 5)
@property
def max_rows(self) -> int:
return self._config.get("general", {}).get("max_rows", 50)
# Global config instance
_config: Config | None = None
def get_config() -> Config:
global _config
if _config is None:
_config = Config()
return _config
"""Unified error types for academic search operations."""
class AcademicSearchError(Exception):
"""Base exception for academic search operations."""
class DataSourceError(AcademicSearchError):
"""Error from a specific data source."""
def __init__(self, source: str, message: str, original_error: Exception | None = None):
self.source = source
self.original_error = original_error
super().__init__(f"[{source}] {message}")
class TimeoutError(AcademicSearchError):
"""Request timeout after retries."""
class ConfigError(AcademicSearchError):
"""Configuration error."""
"""Structured logging for academic search operations."""
import json
import logging
import sys
from datetime import datetime, timezone
class JSONFormatter(logging.Formatter):
def format(self, record):
log_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"tool": getattr(record, "tool", None),
"query": getattr(record, "query", None),
"sources": getattr(record, "sources", None),
"duration_ms": getattr(record, "duration_ms", None),
"results_count": getattr(record, "results_count", None),
"message": record.getMessage(),
}
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
return json.dumps({k: v for k, v in log_data.items() if v is not None})
def setup_logging(level: str = "INFO") -> logging.Logger:
logger = logging.getLogger("academic-search")
logger.setLevel(getattr(logging, level.upper()))
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(JSONFormatter())
logger.addHandler(handler)
return logger
Workflow 3: MeSH Search Strategy
Purpose: Build precise PubMed queries from MeSH terms.
Procedure
1. Use pubmed_lookup_mesh to explore terms related to the topic. 2. Show term hierarchy (broader / narrower / related). 3. Construct Boolean query: MeSH terms + keywords. See Query Construction for templates. 4. Optionally spell-check query with pubmed_spell_check. 5. Execute via pubmed_search_articles.
Output
Final PubMed query string, result count, and top results.