Skip to content
Open
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
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ build-backend = "setuptools.build_meta"
where = ["src"]

[tool.setuptools.package-data]
scansci_pdf = ["data/*.json", "data/*.dat", "_core/*.pyd", "_core/*.so"]
scansci_pdf = [
"data/*.json",
"data/*.dat",
"templates/*.html",
"_core/*.pyd",
"_core/*.so",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
78 changes: 69 additions & 9 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ scansci-pdf 是一个 MCP 服务器,提供 21 个工具,覆盖学术论文
|------|----------|------|
| 按 DOI/arXiv ID 下载单篇论文 | `scansci_pdf_download` | 支持 5 种下载策略(fastest/scihub_only/...) |
| 批量下载多篇论文 | `scansci_pdf_batch_download` | 并发下载,默认 10 线程 |
| 关键词/作者搜索论文 | `scansci_pdf_search` | 基于 OpenAlex,支持关键词、作者名、作者ID |
| 高级文献检索 | `scansci_pdf_search` | 10 个学术 API,支持布尔/字段表达式、结构化过滤与检索 provenance |
| 导出引文 | `scansci_pdf_citation` | BibTeX / RIS / EndNote 三种格式 |
| 导入 .bib 文件并下载 | `scansci_pdf_import_bib` | 自动提取 DOI 并批量下载 |
| 推送到 Zotero | `scansci_pdf_zotero_push` | 需先下载论文到缓存 |
Expand Down Expand Up @@ -82,21 +82,81 @@ scansci-pdf 是一个 MCP 服务器,提供 21 个工具,覆盖学术论文

| 工具 | 描述 | 关键参数 |
|------|------|----------|
| `scansci_pdf_search` | 关键词/作者搜索论文(OpenAlex) | `query`(关键词搜索)、`author`(按作者名)、`author_id`(按作者ID)、`limit`(默认 10)、`year_from`、`year_to`、`sort` |
| `scansci_pdf_search` | 10 个学术 API 的可审计检索,支持高级布尔/字段表达式 | `query`、`sources`、`query_mode`、`limit`、`offset`、日期/类型/OA/被引等过滤 |
| `scansci_pdf_parse_list` | 解析论文列表文件 | `file_path`(必需,.md/.txt/.bib) |

**参数约束:**
- `query`: 关键词搜索。留空时可配合 `author` 或 `author_id` 按作者搜索
- `author`: 作者名(如 "Fang Jingyun"),自动解析为 OpenAlex 作者 ID,支持中英文姓名顺序互换
- `author_id`: OpenAlex 作者 ID(如 "A5102961214"),跳过姓名解析直接搜索
- `sort`: `"cited_by_count"`(被引最多)、`"publication_date"`(最新)、省略为相关性排序
- `year_from` / `year_to`: 整数年份,如 `2020`
**统一高级检索式:**

- 布尔与分组:`AND`、`OR`、`NOT`、括号、双引号短语、`*` 通配符。
- 通用字段:`title:`、`author:`、`abstract:`、`journal:`、`doi:`、`category:`、`mesh:`、`fulltext:`。
- 示例:`title:"climate change" AND (author:Smith OR mesh:"global warming") NOT review`。
- 只选择单一来源时可传入该来源的原生检索式。跨库时,服务会翻译通用字段;无法精确映射的字段会降级为文本搜索,并在 `retrieval.warnings` 中明确报告。

**参数说明:**

| 参数 | 说明 |
|------|------|
| `query` | 普通关键词、DOI、arXiv ID 或高级表达式。仅作者检索或 bioRxiv/medRxiv 日期浏览时可留空 |
| `sources` | 可选子集:`pubmed`、`pmc`、`biorxiv`、`medrxiv`、`arxiv`、`openalex`、`crossref`、`semantic_scholar`、`core`、`unpaywall`;`["all"]` 仅用于明确要求的广泛检索 |
| `query_mode` | `auto`(默认自动识别)、`plain` 或 `advanced` |
| `exact` | 在来源支持时请求精确匹配 |
| `limit` / `offset` | 最终返回 1-100 条;offset 是各来源内部的零基分页位置,不是合并结果的全局偏移;page/token 来源会返回 warning |
| `year_from` / `year_to` | 兼容整数年份过滤 |
| `date_from` / `date_to` | 更精确的 ISO 日期 `YYYY-MM-DD` |
| `sort` | `relevance`、`publication_date`、`updated_date`、`cited_by_count` |
| `author` / `author_id` | 作者名或精确 OpenAlex Author ID |
| `publication_types` | 来源原生文献类型列表,如 `Review`、`JournalArticle`、`journal-article`、`article` |
| `fields_of_study` | Semantic Scholar 学科列表;其他库按主题文本映射 |
| `venue` / `category` | 期刊/会议;或 arXiv、bioRxiv、medRxiv 类别 |
| `open_access_only` | 仅保留已确认 OA 的结果 |
| `has_abstract` | `true` 要求摘要,`false` 排除带摘要记录,省略则不限 |
| `min_citations` / `language` | 最低已知被引数;语言代码/名称 |
| `recent_days` | 1-365,用于 PubMed 或 bioRxiv/medRxiv 最近记录检索;其他来源会提示改用日期范围 |
| `enrich_open_access` | 用 Unpaywall 串行补充前 10 个 DOI;必须先把配置 `email` 设为真实邮箱 |

**自动路由:**

- DOI:Crossref + Semantic Scholar + Unpaywall。
- arXiv ID:arXiv + Semantic Scholar + OpenAlex。
- 一般主题:PubMed + OpenAlex + Semantic Scholar + Crossref。
- bioRxiv/medRxiv 没有关键词 API;主题检索应走 OpenAlex/Semantic Scholar,直接 API 只支持 DOI、日期范围、最近天数和 category。
- Unpaywall 是 DOI 查询/OA 丰富接口,不是可靠的关键词发现库。
- PMC 是全文库;PubMed 主要提供题录与摘要元数据。

**返回值(search):**

```json
{"results": [{"title": "...", "doi": "...", "authors": [...], "year": 2024, "cited_by_count": 42, "abstract": "..."}]}
{
"results": [
{
"title": "...",
"identifier": "...",
"doi": "...",
"authors": ["..."],
"year": 2024,
"cited_by_count": 42,
"is_oa": true,
"source": "openalex+unpaywall"
}
],
"retrieval": {
"query_mode": "advanced",
"scope": "bounded_search",
"sources_requested": ["openalex", "pubmed"],
"access_date": "2026-07-19",
"total_by_source": {"openalex": 120, "pubmed": 85},
"warnings": [],
"errors": {},
"partial": false,
"source_provenance": [
{"source": "openalex", "endpoint": "...", "parameters": {}, "retrieved": 10}
]
}
}
```

所有 API key/token 在 provenance 与配置读取中均脱敏。空结果、来源错误、参数降级或分页限制不会静默隐藏。

### 引文管理

| 工具 | 描述 | 关键参数 |
Expand Down
2 changes: 2 additions & 0 deletions src/scansci_pdf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ def _patched_load_default_certs(self, *args, **kwargs):
"download",
"batch_download",
"search_papers",
"search_papers_detailed",
"load_config",
"update_config",
"get_config_safe",
]

from .sources import download, batch_download
from .search import search_papers
from .advanced_search import search_papers_detailed
from .config import load_config, update_config, get_config_safe
246 changes: 246 additions & 0 deletions src/scansci_pdf/advanced_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""Advanced multi-database literature search orchestration."""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from typing import Any, Callable

from .config import load_config
from .search_provider_arxiv import search_arxiv
from .search_providers_access import (
enrich_unpaywall,
search_core,
search_unpaywall,
)
from .search_providers_biomedical import (
search_biorxiv,
search_medrxiv,
search_pmc,
search_pubmed,
)
from .search_providers_index import (
search_crossref,
search_openalex,
search_semantic_scholar,
)
from .search_query import (
SearchSpec,
SourceReport,
deduplicate_results,
sort_results,
)

Provider = Callable[[SearchSpec, dict[str, Any]], SourceReport]

_PROVIDERS: dict[str, Provider] = {
"pubmed": search_pubmed,
"pmc": search_pmc,
"biorxiv": search_biorxiv,
"medrxiv": search_medrxiv,
"arxiv": search_arxiv,
"openalex": search_openalex,
"crossref": search_crossref,
"semantic_scholar": search_semantic_scholar,
"core": search_core,
"unpaywall": search_unpaywall,
}


def _route_sources(spec: SearchSpec) -> list[str]:
if spec.sources:
return spec.sources
identifier_type, _ = spec.identifier
if identifier_type == "doi":
return ["crossref", "semantic_scholar", "unpaywall"]
if identifier_type == "arxiv":
return ["arxiv", "semantic_scholar", "openalex"]
# Broad default follows paper-lookup's comprehensive-search contract.
return ["pubmed", "openalex", "semantic_scholar", "crossref"]


def _validate_retrieval(spec: SearchSpec, sources: list[str]) -> None:
if spec.effective_query:
return
if spec.author_id:
return
browse_sources = {"biorxiv", "medrxiv"}
if (
set(sources).issubset(browse_sources)
and (spec.date_from or spec.date_to or spec.recent_days)
):
return
raise ValueError(
"query is required unless author/author_id is set, or bioRxiv/medRxiv "
"is browsed with date_from/date_to/recent_days"
)


def search_papers_detailed(
query: str = "",
limit: int = 10,
year_from: int | None = None,
year_to: int | None = None,
sort: str | None = None,
*,
sources: list[str] | str | None = None,
query_mode: str = "auto",
exact: bool = False,
offset: int = 0,
date_from: str | None = None,
date_to: str | None = None,
author: str | None = None,
author_id: str | None = None,
publication_types: list[str] | str | None = None,
fields_of_study: list[str] | str | None = None,
venue: str | None = None,
category: str | None = None,
open_access_only: bool = False,
has_abstract: bool | None = None,
min_citations: int | None = None,
language: str | None = None,
recent_days: int | None = None,
enrich_open_access: bool = False,
config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Run a bounded, auditable search across selected literature databases."""
spec = SearchSpec(
query=query,
sources=sources, # type: ignore[arg-type]
query_mode=query_mode,
exact=exact,
limit=limit,
offset=offset,
year_from=year_from,
year_to=year_to,
date_from=date_from,
date_to=date_to,
sort=sort or "relevance",
author=author,
author_id=author_id,
publication_types=publication_types, # type: ignore[arg-type]
fields_of_study=fields_of_study, # type: ignore[arg-type]
venue=venue,
category=category,
open_access_only=open_access_only,
has_abstract=has_abstract,
min_citations=min_citations,
language=language,
recent_days=recent_days,
enrich_open_access=enrich_open_access,
)
selected_sources = _route_sources(spec)
_validate_retrieval(spec, selected_sources)
runtime_config = config if config is not None else load_config()

reports_by_source: dict[str, SourceReport] = {}
worker_count = max(1, min(4, len(selected_sources)))
with ThreadPoolExecutor(
max_workers=worker_count,
thread_name_prefix="scansci-search",
) as pool:
futures = {
pool.submit(_PROVIDERS[source], spec, runtime_config): source
for source in selected_sources
}
for future in as_completed(futures):
source = futures[future]
try:
reports_by_source[source] = future.result()
except Exception as exc:
reports_by_source[source] = SourceReport(
source=source,
error=f"{type(exc).__name__}: {exc}",
)

reports = [
reports_by_source.get(source, SourceReport(source=source))
for source in selected_sources
]
raw_results = [
result
for report in reports
for result in report.results
]

needs_enrichment = (
spec.enrich_open_access or spec.open_access_only
) and spec.identifier[0] != "doi"
if needs_enrichment:
dois = [result.get("doi", "") for result in raw_results]
oa_report = enrich_unpaywall(dois, runtime_config, max_items=min(10, spec.limit))
reports.append(oa_report)
raw_results.extend(oa_report.results)

merged = deduplicate_results(raw_results)
if spec.open_access_only:
merged = [item for item in merged if item.get("is_oa")]
if spec.has_abstract is not None:
merged = [
item for item in merged
if (
item.get("has_abstract")
if item.get("has_abstract") is not None
else bool(item.get("abstract"))
) is spec.has_abstract
]
if spec.min_citations is not None:
merged = [
item for item in merged
if (item.get("cited_by_count") or 0) >= spec.min_citations
]
sort_results(merged, spec.sort)
merged = merged[:spec.limit]

warnings = [
f"{report.source}: {warning}"
for report in reports
for warning in report.warnings
]
errors = {
report.source: report.error
for report in reports
if report.error
}
source_provenance = [report.provenance() for report in reports]
retrieved_by_source = {
report.source: len(report.results) for report in reports
}
total_by_source = {
report.source: report.total for report in reports
if report.total is not None
}
access_date = datetime.now(timezone.utc).date().isoformat()

return {
"results": merged,
"retrieval": {
"query": spec.query,
"effective_query": spec.effective_query,
"query_mode": "advanced" if spec.advanced else "plain",
"scope": (
"targeted_lookup" if spec.identifier[0]
else "bounded_search"
),
"sources_requested": selected_sources,
"sources_queried": [
report.source for report in reports if report.endpoint
],
"access_date": access_date,
"limit": spec.limit,
"offset": spec.offset,
"offset_scope": "per_source",
"retrieved_by_source": retrieved_by_source,
"total_by_source": total_by_source,
"deduplicated_count": len(merged),
"warnings": warnings,
"errors": errors,
"partial": bool(errors),
"source_provenance": source_provenance,
},
}


def search_papers_advanced(*args: Any, **kwargs: Any) -> list[dict[str, Any]]:
"""Compatibility helper returning only the result list."""
return search_papers_detailed(*args, **kwargs)["results"]
8 changes: 7 additions & 1 deletion src/scansci_pdf/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"ezproxy_login_url": "",
"core_api_key": "",
"openalex_api_key": "",
"semantic_scholar_api_key": "",
"ncbi_api_key": "",
"elsevier_api_key": "",
"elsevier_insttoken": "",
"connect_timeout": 15,
Expand Down Expand Up @@ -186,7 +188,11 @@ def update_config(key: str, value: str) -> dict[str, Any]:

def get_config_safe() -> dict[str, Any]:
config = load_config()
sensitive_keys = ["core_api_key", "vpnsci_cookie_file", "zotero_api_key", "zotero_library_id", "elsevier_api_key", "elsevier_insttoken"]
sensitive_keys = [
"core_api_key", "openalex_api_key", "semantic_scholar_api_key",
"ncbi_api_key", "vpnsci_cookie_file", "zotero_api_key",
"zotero_library_id", "elsevier_api_key", "elsevier_insttoken",
]
for key in sensitive_keys:
if config.get(key):
config[key] = "***"
Expand Down
Loading