Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ PAPER_SEARCH_MCP_DOAJ_API_KEY=
PAPER_SEARCH_MCP_ZENODO_ACCESS_TOKEN=
PAPER_SEARCH_MCP_GOOGLE_SCHOLAR_PROXY_URL=

# Default save directory for CLI download/read/fallback commands.
# If unset, the CLI falls back to D:\documents\文献.
PAPER_SEARCH_MCP_DEFAULT_OUTPUT=D:\documents\文献

# Optional provider-specific keys
PAPER_SEARCH_MCP_OPENAIRE_API_KEY=
PAPER_SEARCH_MCP_CITESEERX_API_KEY=
Expand All @@ -19,5 +23,13 @@ PAPER_SEARCH_MCP_ACM_API_KEY=
# Optional: override env file path if needed
# PAPER_SEARCH_MCP_ENV_FILE=/absolute/path/to/.env

# HTTP/HTTPS proxy (e.g. Clash: http://127.0.0.1:7897)
HTTP_PROXY=
HTTPS_PROXY=

# Sci-Hub: custom mirror URL (overrides built-in mirror list)
# Built-in fallback order: sci-hub.se → sci-hub.st → sci-hub.ru → sci-hub.ren
PAPER_SEARCH_MCP_SCIHUB_URL=

# Backward compatibility:
# legacy names without PAPER_SEARCH_MCP_ prefix are still supported.
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
branches:
- main
- "codex/**"
pull_request:
branches:
- main
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12", "3.13"]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install dependencies
run: uv pip install --system -e . pytest

- name: Compile Python files
run: python -m compileall paper_search_mcp tests

- name: Run tests
run: python -m pytest -q

- name: Smoke test CLI
run: python -m paper_search_mcp.cli sources
55 changes: 42 additions & 13 deletions paper_search_mcp/academic_platforms/arxiv.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .base import PaperSource
from pypdf import PdfReader
import os
from ..file_naming import paper_output_path

class ArxivSearcher(PaperSource):
"""Searcher for arXiv papers"""
Expand All @@ -29,16 +30,20 @@ def search(self, query: str, max_results: int = 10, sort_by: str = 'relevance',
'sortOrder': sort_order,
}
response = None
for attempt in range(3):
for attempt in range(4):
try:
response = self.session.get(self.BASE_URL, params=params, timeout=30)
except requests.RequestException:
time.sleep((attempt + 1) * 1.5)
time.sleep(3 ** attempt)
continue
if response.status_code == 200:
break
if response.status_code in (429, 500, 502, 503, 504):
time.sleep((attempt + 1) * 1.5)
if response.status_code == 429:
# arXiv rate limit — back off significantly
time.sleep(10 * (attempt + 1))
continue
if response.status_code in (500, 502, 503, 504):
time.sleep(3 ** attempt)
continue
break

Expand Down Expand Up @@ -80,12 +85,37 @@ def search(self, query: str, max_results: int = 10, sort_by: str = 'relevance',

def download_pdf(self, paper_id: str, save_path: str) -> str:
pdf_url = f"https://arxiv.org/pdf/{paper_id}.pdf"
response = requests.get(pdf_url)
os.makedirs(save_path, exist_ok=True)
output_file = f"{save_path}/{paper_id}.pdf"
response = self.session.get(pdf_url, timeout=60)
response.raise_for_status()
metadata = self._metadata_for_id(paper_id)
output_file = paper_output_path(
save_path,
title=metadata.get("title", ""),
authors=metadata.get("authors", []),
published_date=metadata.get("published_date", ""),
identifier=paper_id,
extension=".pdf",
)
with open(output_file, 'wb') as f:
f.write(response.content)
return output_file
return str(output_file)

def _metadata_for_id(self, paper_id: str) -> dict:
try:
response = self.session.get(self.BASE_URL, params={"id_list": paper_id}, timeout=30)
response.raise_for_status()
feed = feedparser.parse(response.content)
if not feed.entries:
return {}
entry = feed.entries[0]
published = datetime.strptime(entry.published, '%Y-%m-%dT%H:%M:%SZ')
return {
"title": entry.title,
"authors": [author.name for author in entry.authors],
"published_date": published,
}
except Exception:
return {}

def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
"""Read a paper and convert it to text format.
Expand All @@ -97,10 +127,9 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
Returns:
str: The extracted text content of the paper
"""
# First ensure we have the PDF
pdf_path = f"{save_path}/{paper_id}.pdf"
if not os.path.exists(pdf_path):
pdf_path = self.download_pdf(paper_id, save_path)
# First ensure we have the PDF. The filename is metadata-based, so
# always ask the downloader for the actual path.
pdf_path = self.download_pdf(paper_id, save_path)

# Read the PDF
try:
Expand Down Expand Up @@ -154,4 +183,4 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
print(text_content[:500] + "...")
print(f"\nTotal length of extracted text: {len(text_content)} characters")
except Exception as e:
print(f"Error during paper reading: {e}")
print(f"Error during paper reading: {e}")
15 changes: 9 additions & 6 deletions paper_search_mcp/academic_platforms/base_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import logging
from .oaipmh import OAIPMHSearcher
from ..paper import Paper
from ..file_naming import paper_output_path_for_paper

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -186,16 +187,18 @@ def download_pdf(self, paper_id: str, save_path: str) -> str:
response.raise_for_status()
os.makedirs(save_path, exist_ok=True)

# Create safe filename
safe_id = paper_id.replace('/', '_').replace(':', '_')
filename = f"base_{safe_id}.pdf"
output_file = os.path.join(save_path, filename)
output_file = paper_output_path_for_paper(
save_path,
paper,
identifier=str(paper_id),
extension=".pdf",
)

with open(output_file, 'wb') as f:
f.write(response.content)

logger.info(f"Downloaded PDF to {output_file}")
return output_file
return str(output_file)

raise NotImplementedError(
f"No PDF available for BASE record: {paper_id}"
Expand Down Expand Up @@ -250,4 +253,4 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
print(f" Source: {paper.source}")
print(f" PDF: {'Yes' if paper.pdf_url else 'No'}")
print(f" URL: {paper.url}")
print()
print()
21 changes: 14 additions & 7 deletions paper_search_mcp/academic_platforms/biorxiv.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from datetime import datetime, timedelta
from ..paper import Paper
from .base import PaperSource
from ..crossref_resolver import metadata_for_identifier
from ..file_naming import paper_output_path
from pypdf import PdfReader

class BioRxivSearcher(PaperSource):
Expand Down Expand Up @@ -107,11 +109,18 @@ def download_pdf(self, paper_id: str, save_path: str) -> str:
}
response = self.session.get(pdf_url, timeout=self.timeout, headers=headers)
response.raise_for_status()
os.makedirs(save_path, exist_ok=True)
output_file = f"{save_path}/{paper_id.replace('/', '_')}.pdf"
metadata = metadata_for_identifier(paper_id)
output_file = paper_output_path(
save_path,
title=metadata.get("title", ""),
authors=metadata.get("authors", []),
published_date=metadata.get("published_date", ""),
identifier=paper_id,
extension=".pdf",
)
with open(output_file, 'wb') as f:
f.write(response.content)
return output_file
return str(output_file)
except requests.exceptions.RequestException as e:
tries += 1
if tries == self.max_retries:
Expand All @@ -129,9 +138,7 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
Returns:
str: The extracted text content of the paper
"""
pdf_path = f"{save_path}/{paper_id.replace('/', '_')}.pdf"
if not os.path.exists(pdf_path):
pdf_path = self.download_pdf(paper_id, save_path)
pdf_path = self.download_pdf(paper_id, save_path)

try:
reader = PdfReader(pdf_path)
Expand All @@ -141,4 +148,4 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
return text.strip()
except Exception as e:
print(f"Error reading PDF for paper {paper_id}: {e}")
return ""
return ""
15 changes: 9 additions & 6 deletions paper_search_mcp/academic_platforms/chemrxiv.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import logging
from .crossref import CrossRefSearcher
from ..paper import Paper
from ..file_naming import paper_output_path_for_paper

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -115,16 +116,18 @@ def download_pdf(self, paper_id: str, save_path: str) -> str:
response.raise_for_status()
os.makedirs(save_path, exist_ok=True)

# Create safe filename
safe_id = paper_id.replace('/', '_').replace(':', '_')
filename = f"chemrxiv_{safe_id}.pdf"
output_file = os.path.join(save_path, filename)
output_file = paper_output_path_for_paper(
save_path,
paper,
identifier=str(paper_id),
extension=".pdf",
)

with open(output_file, 'wb') as f:
f.write(response.content)

logger.info(f"Downloaded PDF to {output_file}")
return output_file
return str(output_file)

raise NotImplementedError(
f"No PDF available for ChemRxiv preprint: {paper_id}"
Expand Down Expand Up @@ -180,4 +183,4 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
print(f" Year: {paper.published_date.year if paper.published_date else 'Unknown'}")
print(f" DOI: {paper.doi}")
print(f" PDF: {'Yes' if paper.pdf_url else 'No'}")
print()
print()
16 changes: 9 additions & 7 deletions paper_search_mcp/academic_platforms/citeseerx.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..paper import Paper
from ..utils import extract_doi
from ..config import get_env
from ..file_naming import paper_output_path_for_paper
from .base import PaperSource

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -320,19 +321,20 @@ def download_pdf(self, paper_id: str, save_path: str) -> str:
# Create save directory if it doesn't exist
os.makedirs(save_path, exist_ok=True)

# Generate filename
filename = f"{paper_id.replace('/', '_')}.pdf"
if paper.doi:
filename = f"{paper.doi.replace('/', '_')}.pdf"
filepath = os.path.join(save_path, filename)
filepath = paper_output_path_for_paper(
save_path,
paper,
identifier=paper.doi or str(paper_id),
extension=".pdf",
)

# Save PDF
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)

logger.info(f"Downloaded PDF to {filepath}")
return filepath
return str(filepath)

except requests.RequestException as e:
logger.error(f"Error downloading PDF: {e}")
Expand Down Expand Up @@ -404,4 +406,4 @@ def read_paper(self, paper_id: str, save_path: str = "./downloads") -> str:
print(f" Year: {paper.extra.get('year', 'N/A')}")
print(f" Venue: {paper.extra.get('venue', 'N/A')}")
print(f" Citations: {paper.citations}")
print(f" URL: {paper.url}")
print(f" URL: {paper.url}")
Loading