download-fulltext-pdf
当用户明确要求"下载文献全文"或"获取论文PDF"时使用。通过 DOI 号下载学术论文全文 PDF,支持 arXiv、Sci-Hub、Unpaywall、期刊官网等多源策略。⚠️ 不适用:用户只是想解析或处理已有的 PDF 文件(应使用 pdf skill)、只是想搜索论文信息而无需下载全文、没有提供 DOI/标题/BibTeX 任何标识符。
Install
npx skills add https://github.com/huangwb8/skills/tree/main/skills/beta/download-fulltext-pdf
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install huangwb8-skills@llmmart
git clone https://github.com/huangwb8/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole huangwb8/skills collection as a plugin from our marketplace. Git is the plain clone.
README
download-fulltext-pdf
这个 skill 用来尽可能把目标论文的全文 PDF 下载到本地,适合 DOI、标题或 BibTeX 驱动的全文获取;如果你已经有 PDF,只是想解析、提取或处理它,就不该用它。
用法
最推荐用法
请使用 download-fulltext-pdf skill 下载这篇论文的全文 PDF。
输入:DOI、标题或 BibTeX,以及输出目录
输出:下载到本地的 PDF 文件;若失败,给出各数据源的失败原因
进阶用法
请使用 download-fulltext-pdf skill 获取这篇论文的全文。
输入:DOI `10.1038/nature09492`,输出目录 `./papers`
输出:本地 PDF 文件
另外,还有下列参数约束:
- 优先尝试开放获取来源
- 若失败,保留每个数据源的失败原因
- 不覆盖已存在文件
能做什么
- 根据 DOI、标题或 BibTeX 识别目标论文。
- 按多源策略尝试下载 PDF。
- 对下载结果做基本验证,避免把 HTML 错页当成 PDF。
- 失败时给出来源级错误信息,便于你继续排查。
- 不适合处理已有 PDF,也不负责论文内容分析。
使用示例
示例 1:按 DOI 下载
请使用 download-fulltext-pdf skill 下载这篇论文的全文 PDF。
输入:DOI `10.1038/nature09492`,输出目录 `./papers`
输出:下载好的 PDF 文件
示例 2:按 BibTeX 下载
请使用 download-fulltext-pdf skill 下载这篇文献。
输入:BibTeX 条目,以及输出目录 `./downloads`
输出:本地 PDF 文件或失败原因报告
示例 3:要求明确失败报告
请使用 download-fulltext-pdf skill 获取这篇论文的全文。
输入:论文标题和输出目录
输出:PDF 文件
另外,还有下列参数约束:
- 如果全部失败,列出每个数据源的失败原因
输出
- 成功时:输出一个本地
.pdf文件。 - 默认文件名:
paper.pdf - 默认不会覆盖已有输出。
- 验证失败时,会继续尝试下一个数据源。
- 所有来源都失败时,会返回详细失败说明。
配置
- 配置文件:
download-fulltext-pdf/config.yaml - 常见数据源顺序:
arXivSci-HubUnpaywall- 期刊官网兜底
- 默认输出覆盖:
false - 常见关键配置:
download.scihubdownload.arxivdownload.unpaywallverificationoutput
备选用法(脚本/硬编码)
如果你已经明确知道 DOI 和目标输出路径,脚本方式最直接。
输入校验
python3 download-fulltext-pdf/scripts/validate_input.py \
10.1038/nature09492 \
./papers
执行下载
python3 download-fulltext-pdf/scripts/download_pdf.py \
10.1038/nature09492 \
./papers
校验 PDF
python3 download-fulltext-pdf/scripts/verify_pdf.py \
./papers/paper.pdf
常见问题
Q:为什么失败报告里会出现多个来源?
A:因为这个 skill 会按顺序尝试多个来源,只要前一个失败,就继续尝试下一个。
Q:下载到了文件,但打不开怎么办?
A:这通常意味着拿到的是 HTML 页面或损坏文件。技能会尽量做 PDF 头和可解析性检查,但你仍可以再运行 verify_pdf.py 复核。
Q:会覆盖我已经下载好的 PDF 吗?
A:默认不会。配置里的 output.overwrite 默认为 false。
Q:它能帮我总结论文吗?
A:不能。它的职责是“拿到 PDF”,不是“阅读或分析 PDF”。
Skill manifest
下载文献全文 PDF
目标
当用户明确要求"下载文献全文"或"获取论文PDF"时使用。通过 DOI 号下载学术论文全文 PDF,支持 arXiv、Sci-Hub、Unpaywall、期刊官网等多源策略。⚠️ 不适用:用户只是想解析或处理已有的 PDF 文件(应使用 pdf skill)、只是想搜索论文信息而无需下载全文、没有提供 DOI/标题/BibTeX 任何标识符。
流程
输入
输入至少包含 DOI、标题或 BibTeX 之一,并指定输出目录/文件路径;可选输入包括来源开关、Unpaywall 邮箱、验证阈值和覆盖策略。输出路径必须存在或可创建且可写,触发边界以本 Skill 的 ## 目标 为准。
执行步骤
核心工作流
1. 输入验证与规范化
必需参数:
doi: DOI 号(如10.1038/nature09492)output_path: 输出目录或完整 PDF 文件路径
可选参数:
title: 论文标题(作为 DOI 的备选)bibtex: BibTeX 条目(包含 DOI 或标题信息)
验证逻辑:
# 由 scripts/validate_input.py 处理
- DOI 格式规范化(移除多余空格、补全前缀)
- 输出路径检查(目录存在性、写入权限)
- 至少提供一种标识符(DOI/title/bibtex)
2. 多源下载策略
优先级顺序(按成功率和速度排序):
| 优先级 | 数据源 | 优势 | 局限 | 触发条件 |
|---|---|---|---|---|
| 1 | arXiv | 稳定可靠 | 仅限预印本论文 | 检测到 arXiv ID(如 10.48550/arXiv.*) |
| 2 | Sci-Hub | 覆盖较广、速度快 | 可能有 CAPTCHA | 非 arXiv 或 arXiv 失败 |
| 3 | Unpaywall | 合法开放获取(OA) | 取决于论文是否 OA | Sci-Hub 失败时兜底 |
| 4 | 期刊官网 | 合法渠道 | 常需订阅/登录 | 最后兜底 |
策略执行:
# 由 scripts/download_pdf.py 实现
1. 如 DOI 可解析出 arXiv ID → 优先 arXiv
2. 尝试 Sci-Hub(通过 scihub 库)
3. 尝试 Unpaywall(合法 OA 获取)
4. 尝试 DOI 落地页并猜测常见 PDF 路径(期刊官网兜底)
3. 下载后验证
必需检查(由 scripts/verify_pdf.py 处理):
- 文件大小 > 0 且非 HTML 页面
- PDF 文件头正确(
%PDF-) - 文件可被 PyPDF2 解析
失败处理:
- 验证失败 → 记录错误 → 尝试下一个数据源
- 所有源失败 → 返回详细错误报告
4. 输出格式
成功时:
✅ 成功下载论文全文
**来源**: Sci-Hub
**DOI**: 10.1038/nature09492
**文件**: /path/to/paper.pdf
**大小**: 2.3 MB
失败时:
❌ 无法下载论文全文
**尝试过的源**:
- Sci-Hub: CAPTCHA 验证失败
- arXiv: 非 arXiv 论文
- Unpaywall: 无开放获取版本
- 期刊官网: 未找到直接 PDF 链接
**建议**:
1. 手动访问 Sci-Hub 并完成 CAPTCHA 验证
2. 检查 DOI 是否正确
3. 尝试通过期刊官网获取
AI 动态判断
AI 需要动态处理的场景:
| 场景 | 处理方式 |
|---|---|
| CAPTCHA 检测 | 识别错误消息中的 "captcha" 关键词 → 切换数据源 |
| 网络超时 | 增加重试次数(最多 3 次)或切换源 |
| 404/未找到 | 直接切换下一个源,不重试 |
| 文件损坏 | 删除已下载文件,尝试下一个源 |
| arXiv 检测 | DOI 中包含 arXiv ID → 优先使用 arXiv 源 |
硬编码操作(scripts/)
scripts/validate_input.py
输入验证与规范化脚本:
import sys
import re
from pathlib import Path
def normalize_doi(doi: str) -> str:
"""规范化 DOI 格式"""
doi = doi.strip()
if not doi.startswith("10."):
doi = f"10.{doi}" # 尝试补全
return doi
def validate_output_path(path: str) -> Path:
"""验证输出路径"""
path = Path(path).expanduser().resolve()
if path.exists() and path.is_dir():
# 目录:自动生成文件名
return path / "paper.pdf"
if path.parent.exists():
# 完整路径:确保父目录可写
return path
raise ValueError(f"输出路径无效: {path}")
def main():
if len(sys.argv) < 3:
print("用法: python validate_input.py <doi> <output_path>", file=sys.stderr)
sys.exit(1)
doi = normalize_doi(sys.argv[1])
output_path = validate_output_path(sys.argv[2])
print(f"DOI:{doi}")
print(f"OUTPUT:{output_path}")
if __name__ == "__main__":
main()
scripts/download_pdf.py
核心下载逻辑(多源策略):
import sys
import requests
from pathlib import Path
from typing import Optional, Tuple
# 尝试导入 scihub 库(可选依赖)
try:
from scihub import SciHub
HAS_SCIHUB = True
except ImportError:
HAS_SCIHUB = False
class PDFDownloader:
"""多源 PDF 下载器"""
def __init__(self, doi: str, output_path: Path):
self.doi = doi
self.output_path = output_path
self.sources_tried = []
def download_from_scihub(self) -> Tuple[bool, str]:
"""从 Sci-Hub 下载"""
if not HAS_SCIHUB:
return False, "scihub 库未安装"
try:
sh = SciHub()
result = sh.download(self.doi, path=str(self.output_path))
if result and self.output_path.exists():
return True, "Sci-Hub"
return False, "下载失败"
except Exception as e:
error_msg = str(e).lower()
if "captcha" in error_msg:
return False, "CAPTCHA 验证失败"
return False, str(e)
def download_from_arxiv(self) -> Tuple[bool, str]:
"""从 arXiv 下载(检测 arXiv ID)"""
# 简化实现:arXiv 直接下载 URL
arxiv_id = self._extract_arxiv_id()
if not arxiv_id:
return False, "非 arXiv 论文"
url = f"https://arxiv.org/pdf/{arxiv_id}.pdf"
try:
response = requests.get(url, timeout=30)
if response.status_code == 200:
self.output_path.write_bytes(response.content)
return True, "arXiv"
return False, f"HTTP {response.status_code}"
except Exception as e:
return False, str(e)
def download_from_unpaywall(self) -> Tuple[bool, str]:
"""从 Unpaywall 下载(合法 OA)"""
# Unpaywall API 端点
# email 参数建议从 config.yaml 读取;可配置 emails 列表做负载均衡
url = f"https://api.unpaywall.org/v2/{self.doi}"
try:
response = requests.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
if data.get("is_oa"):
pdf_url = data.get("best_oa_location", {}).get("url_for_pdf")
if pdf_url:
pdf_resp = requests.get(pdf_url, timeout=30)
if pdf_resp.status_code == 200:
self.output_path.write_bytes(pdf_resp.content)
return True, "Unpaywall"
return False, "无开放获取版本"
except Exception as e:
return False, str(e)
def _extract_arxiv_id(self) -> Optional[str]:
"""从 DOI 或上下文中提取 arXiv ID"""
# 简化实现:检测 DOI 中是否包含 arXiv 信息
# 实际应解析 BibTeX 或标题
return None
def download(self) -> Tuple[bool, str, str]:
"""执行多源下载策略"""
# 策略 1: Sci-Hub
self.sources_tried.append("Sci-Hub")
success, source = self.download_from_scihub()
if success:
return True, source, "成功"
# 策略 2: arXiv(如果是 arXiv 论文)
self.sources_tried.append("arXiv")
success, source = self.download_from_arxiv()
if success:
return True, source, "成功"
# 策略 3: Unpaywall
self.sources_tried.append("Unpaywall")
success, source = self.download_from_unpaywall()
if success:
return True, source, "成功"
# 所有源失败
return False, "", f"尝试过的源: {', '.join(self.sources_tried)}"
def main():
if len(sys.argv) != 3:
print("用法: python download_pdf.py <doi> <output_path>", file=sys.stderr)
sys.exit(1)
doi = sys.argv[1]
output_path = Path(sys.argv[2]).expanduser().resolve()
downloader = PDFDownloader(doi, output_path)
success, source, message = downloader.download()
if success:
print(f"SUCCESS:{source}")
print(f"FILE:{output_path}")
print(f"SIZE:{output_path.stat().st_size}")
else:
print(f"FAILURE:{message}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
scripts/verify_pdf.py
PDF 验证脚本:
import sys
from pathlib import Path
def verify_pdf(path: Path) -> Tuple[bool, str]:
"""验证 PDF 文件完整性"""
if not path.exists():
return False, "文件不存在"
if path.stat().st_size == 0:
return False, "文件大小为 0"
# 检查 PDF 文件头
with open(path, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
return False, "无效的 PDF 文件头"
# 尝试用 PyPDF2 解析
try:
import PyPDF2
with open(path, "rb") as f:
PyPDF2.PdfReader(f)
return True, "有效 PDF"
except ImportError:
# PyPDF2 未安装,跳过深度验证
return True, "未安装 PyPDF2,跳过深度验证"
except Exception as e:
return False, f"PDF 解析失败: {str(e)}"
def main():
if len(sys.argv) != 2:
print("用法: python verify_pdf.py <pdf_path>", file=sys.stderr)
sys.exit(1)
path = Path(sys.argv[1]).expanduser().resolve()
is_valid, message = verify_pdf(path)
if is_valid:
print(f"VALID:{message}")
sys.exit(0)
else:
print(f"INVALID:{message}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
配置参数(config.yaml)
# 下载策略配置
download:
# Sci-Hub 配置
scihub:
enabled: true
timeout: 30 # 秒
retries: 3
# arXiv 配置
arxiv:
enabled: true
timeout: 30
# Unpaywall 配置
unpaywall:
enabled: true
emails:
- "hwb2012@qq.com"
- "huangwb886@gmail.com"
email_strategy: "round_robin" # round_robin | hash_doi | random
state_file: ".bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/download-fulltext-pdf/state/unpaywall_email_state.json"
email: "" # 兼容旧字段(可选)
# 验证配置
verification:
check_pdf_header: true
require_pypdf2: false # 是否强制要求 PyPDF2
max_file_size: 100_000_000 # 100 MB
# 输出配置
output:
default_filename: "paper.pdf"
overwrite: false # 是否覆盖已存在文件
依赖说明
必需依赖:
requests(网络请求)
可选依赖(自动安装):
scihub(Sci-Hub 支持,首次使用时自动安装)PyPDF2(PDF 验证,推荐安装)
自动安装机制
当 AI 执行下载任务时,scripts/download_pdf.py 会:
- 检测缺失的可选依赖
- 提醒用户将要安装的包(如
📦 检测到缺失依赖: scihub) - 自动安装(使用
pip install) - 安装失败时自动降级到其他数据源
用户无需手动安装,skill 会"开箱即用"。
手动安装(可选)
如果自动安装失败,用户可以手动安装:
pip install requests scihub PyPDF2
使用示例
示例 1:使用 DOI 下载
/skill download-fulltext-pdf "10.1038/nature09492" ./downloads/
示例 2:使用标题下载
/skill download-fulltext-pdf --title "Deep Learning" ./downloads/
示例 3:批量下载(从 BibTeX 文件)
/skill download-fulltext-pdf --bibtex references.bib ./downloads/
输出
成功时交付经 %PDF- 文件头和可选 PyPDF2 解析验证的 PDF,并报告来源、规范化 DOI、文件路径和大小;失败时列出尝试过的来源、具体原因和可操作建议。状态文件、日志和调试材料留在任务工作区。
输出管理
BenszAPI 任务工作区
校验
质量检查清单
- DOI 格式正确(以
10.开头) - 输出路径可写
- 至少一种数据源可用
- 下载后 PDF 验证通过
- 错误消息清晰可操作
失败与恢复
错误处理
| 错误类型 | 表现 | 处理方式 |
|---|---|---|
| CAPTCHA | Sci-Hub 返回验证码页面 | 切换到 Unpaywall 或期刊官网 |
| 未找到 | 404 或 "not found" | 切换下一个数据源 |
| 超时 | 请求超时 | 重试(最多 3 次) |
| 文件损坏 | PDF 头无效 | 删除并重新下载 |
注意事项
- 版权合规:仅用于学术研究,遵守当地版权法律
- Sci-Hub 可用性:Sci-Hub 域名可能变化,需要定期更新
- 网络环境:某些地区可能需要代理才能访问 Sci-Hub
- CAPTCHA 处理:遇到 CAPTCHA 时,建议手动完成验证
约束
公共硬约束
本块由 docs/templates/skill-common-constraints.md 统一维护;每个 SKILL.md 的 ## 约束 必须逐字同步本块,不得在副本中改写公共规则。
- 任务需要落盘时,使用唯一的
./.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/根目录;共享材料放入shared/,Skill 专属材料放入该 Skill 的input/、output/、log/。 - 正式交付物、源代码和正式计划按项目约定保存,不写入任务工作区;未经授权不覆盖、删除、迁移或远程写入。
- 项目维护变更检查 BAC 可用性并记录需求、AI 产出、工具结果、文件改动和验证摘要;BAC 只做过程审计,不替代署名、责任或合规判断。
- 不记录 API Key、访问令牌、密码、Cookie、环境/凭据文件、私有 Prompt、身份信息、本地用户名、主机名或不必要的大体积原始数据。
- 文件路径必须规范化并限制在授权项目范围内;外部 URL、子进程和网络访问遵循最小权限,防止路径遍历、SSRF 和命令注入。
- Skill 版本唯一记录在自身
config.yaml:skill_info.version;公开 API、协议、目录或配置变更同步文档与CHANGELOG.md。 bensz-collect-bugs是一个 Agent Skill;仅将 Bensz Agent Skill 或 Bensz 基础设施本身的设计缺陷交给它。先脱敏写入~/.bensz-skills/bugs/,当前任务不中断,只有用户明确要求才公开上报,禁止直接修改用户已安装的 Skill 源码。
Files (skills)
-
scripts
-
download_pdf.py 16.6 KB
#!/usr/bin/env python3 """ 核心下载逻辑(多源策略) """ import sys import re import json import hashlib import random import os import requests from pathlib import Path from typing import Optional, Tuple, List, Dict, Any # 尝试导入可选依赖 try: from scihub import SciHub HAS_SCIHUB = True except ImportError: HAS_SCIHUB = False # 自动安装缺失的可选依赖 def auto_install_dependencies(): """自动安装缺失的可选依赖""" missing = [] if not HAS_SCIHUB: missing.append("scihub") if not missing: return True # 提醒用户将要安装的包 print(f"📦 检测到缺失依赖: {', '.join(missing)}", file=sys.stderr) print(f"💡 正在自动安装,请稍候...", file=sys.stderr) import subprocess for package in missing: try: print(f" 安装 {package}...", file=sys.stderr) result = subprocess.run( [sys.executable, "-m", "pip", "install", package], capture_output=True, text=True, timeout=120 ) if result.returncode == 0: print(f" ✅ {package} 安装成功", file=sys.stderr) else: print(f" ❌ {package} 安装失败: {result.stderr}", file=sys.stderr) return False except subprocess.TimeoutExpired: print(f" ❌ {package} 安装超时", file=sys.stderr) return False except Exception as e: print(f" ❌ {package} 安装出错: {e}", file=sys.stderr) return False return True class PDFDownloader: """多源 PDF 下载器""" def __init__(self, doi: str, output_path: Path, config: dict = None): self.doi = doi.strip() self.output_path = output_path self.config = config or {} self.sources_tried = [] # 从配置中获取参数 self.scihub_config = self.config.get("download", {}).get("scihub", {}) self.arxiv_config = self.config.get("download", {}).get("arxiv", {}) self.unpaywall_config = self.config.get("download", {}).get("unpaywall", {}) def _safe_relpath_under_cwd(self, relpath: str) -> Path: """ 将相对路径安全地解析到 cwd 下。 - 禁止绝对路径 - 禁止包含 '..' 进行越界 """ if not relpath: raise ValueError("state_file 不能为空") p = Path(relpath) if p.is_absolute(): raise ValueError(f"state_file 必须是相对路径: {relpath}") if any(part == ".." for part in p.parts): raise ValueError(f"state_file 不允许包含 '..': {relpath}") cwd = Path.cwd().resolve() resolved = (cwd / p).resolve() try: resolved.relative_to(cwd) except ValueError as exc: raise ValueError(f"state_file 不能通过符号链接越出当前工作目录: {relpath}") from exc return resolved def _normalize_emails(self) -> List[str]: """从配置读取邮箱列表(兼容 emails / email 两种字段),并去重保序。""" emails: List[str] = [] raw_emails = self.unpaywall_config.get("emails") if isinstance(raw_emails, list): emails.extend([str(x).strip() for x in raw_emails if str(x).strip()]) legacy = str(self.unpaywall_config.get("email", "")).strip() if legacy and not emails: emails.append(legacy) # 允许通过环境变量提供速率限制邮箱,避免在发布配置中固化个人地址。 if not emails: env_emails = os.environ.get("UNPAYWALL_EMAILS", "") emails.extend(item.strip() for item in env_emails.split(",") if item.strip()) # 去重(保序) seen = set() out: List[str] = [] for e in emails: if e not in seen: seen.add(e) out.append(e) return out def _choose_unpaywall_email_order(self, emails: List[str]) -> List[str]: """ 选择本次请求优先使用的 email,并生成“重试顺序”(用于 429/5xx 等降级重试)。 支持策略: - round_robin:持久化轮询(state_file 相对 cwd) - hash_doi:按 DOI 哈希分桶(无状态,默认更可复现) - random:随机 """ if not emails: return [] if len(emails) == 1: return emails[:] strategy = str(self.unpaywall_config.get("email_strategy", "hash_doi")).strip() or "hash_doi" strategy = strategy.lower() start_idx = 0 if strategy == "random": start_idx = random.randrange(len(emails)) elif strategy == "round_robin": state_file = str(self.unpaywall_config.get("state_file", ".bensz-api/skills/download-fulltext-pdf/state/unpaywall_email_state.json")) try: state_path = self._safe_relpath_under_cwd(state_file) state_path.parent.mkdir(parents=True, exist_ok=True) state: Dict[str, Any] = {} if state_path.exists(): try: state = json.loads(state_path.read_text(encoding="utf-8")) except Exception: state = {} start_idx = int(state.get("next_index", 0)) % len(emails) state["next_index"] = (start_idx + 1) % len(emails) state_path.write_text(json.dumps(state, ensure_ascii=True), encoding="utf-8") except Exception: # 轮询状态文件写入失败时,降级到 hash_doi(无状态) strategy = "hash_doi" if strategy == "hash_doi": h = hashlib.sha256(self.doi.encode("utf-8")).hexdigest() start_idx = int(h, 16) % len(emails) ordered = emails[start_idx:] + emails[:start_idx] return ordered def _pick_unpaywall_pdf_urls(self, data: Dict[str, Any]) -> List[str]: """从 Unpaywall 响应里提取候选 PDF URL(优先 url_for_pdf,其次 url)。""" urls: List[str] = [] def add_location(loc: Any) -> None: if not isinstance(loc, dict): return pdf_url = loc.get("url_for_pdf") if isinstance(pdf_url, str) and pdf_url.strip(): urls.append(pdf_url.strip()) return url = loc.get("url") if isinstance(url, str) and url.strip(): urls.append(url.strip()) add_location(data.get("best_oa_location")) oa_locations = data.get("oa_locations") if isinstance(oa_locations, list): for loc in oa_locations: add_location(loc) # 去重保序 seen = set() out: List[str] = [] for u in urls: if u not in seen: seen.add(u) out.append(u) return out def download_from_scihub(self) -> Tuple[bool, str]: """从 Sci-Hub 下载""" global HAS_SCIHUB # 首次使用时尝试自动安装 if not HAS_SCIHUB: if auto_install_dependencies(): # 尝试重新导入 try: from scihub import SciHub HAS_SCIHUB = True except ImportError: return False, "scihub 库安装失败" else: return False, "scihub 库自动安装失败,请手动运行: pip install scihub" if not self.scihub_config.get("enabled", True): return False, "Sci-Hub 已禁用" try: sh = SciHub() # 使用配置的域名(如果有) domain = self.scihub_config.get("domain") if domain: sh.base_domain = domain # 尝试下载 result = sh.download(self.doi, path=str(self.output_path)) if result and self.output_path.exists(): return True, "Sci-Hub" return False, "下载失败" except Exception as e: error_msg = str(e).lower() if "captcha" in error_msg or "cloudflare" in error_msg: return False, "CAPTCHA 验证失败" if "not found" in error_msg or "404" in error_msg: return False, "未找到文献" return False, str(e) def download_from_arxiv(self) -> Tuple[bool, str]: """从 arXiv 下载""" if not self.arxiv_config.get("enabled", True): return False, "arXiv 已禁用" arxiv_id = self._extract_arxiv_id() if not arxiv_id: return False, "非 arXiv 论文" base_url = self.arxiv_config.get("base_url", "https://arxiv.org/pdf") url = f"{base_url}/{arxiv_id}.pdf" try: timeout = self.arxiv_config.get("timeout", 30) response = requests.get(url, timeout=timeout) if response.status_code == 200: # 验证内容是 PDF content = response.content if content.startswith(b"%PDF-"): self.output_path.write_bytes(content) return True, "arXiv" return False, "返回内容非 PDF 格式" elif response.status_code == 404: return False, "arXiv 中未找到" else: return False, f"HTTP {response.status_code}" except requests.Timeout: return False, "请求超时" except Exception as e: return False, str(e) def download_from_unpaywall(self) -> Tuple[bool, str]: """从 Unpaywall 下载(合法开放获取)""" if not self.unpaywall_config.get("enabled", True): return False, "Unpaywall 已禁用" api_url = self.unpaywall_config.get("api_url", "https://api.unpaywall.org/v2") emails = self._normalize_emails() email_order = self._choose_unpaywall_email_order(emails) url = f"{api_url}/{self.doi}" try: timeout = self.unpaywall_config.get("timeout", 30) last_status: Optional[int] = None last_err: Optional[str] = None # 无邮箱时也允许请求(但某些情况下会被拒绝/限流) attempts = email_order if email_order else [""] for email in attempts: params = {} if email: params["email"] = email response = requests.get(url, params=params, timeout=timeout) last_status = response.status_code if response.status_code == 200: data = response.json() break # 这些状态码换邮箱/重试有可能改善;否则直接失败即可。 last_err = f"Unpaywall API 返回 {response.status_code}" if response.status_code in (401, 403, 429) or 500 <= response.status_code <= 599: continue return False, last_err else: return False, last_err or f"Unpaywall API 返回 {last_status}" # 检查是否有开放获取版本 if not data.get("is_oa"): return False, "无开放获取版本" # 提取候选 URL(best_oa_location + oa_locations) candidate_urls = self._pick_unpaywall_pdf_urls(data) if not candidate_urls: return False, "无可用的 OA 链接(缺少 url_for_pdf/url)" # 下载 PDF(逐个尝试) last_pdf_status: Optional[int] = None for pdf_url in candidate_urls: pdf_response = requests.get(pdf_url, timeout=timeout, allow_redirects=True) last_pdf_status = pdf_response.status_code if pdf_response.status_code != 200: continue content = pdf_response.content if content.startswith(b"%PDF-"): self.output_path.write_bytes(content) return True, "Unpaywall" return False, f"PDF 下载失败: HTTP {last_pdf_status}" except requests.Timeout: return False, "请求超时" except Exception as e: return False, str(e) def download_from_direct(self) -> Tuple[bool, str]: """尝试直接从期刊官网下载""" # 构建 DOI 解析 URL doi_url = f"https://doi.org/{self.doi}" try: # 获取重定向后的 URL(实际期刊页面) response = requests.get(doi_url, timeout=30, allow_redirects=True) final_url = response.url # 尝试常见的 PDF 路径模式 pdf_patterns = [ f"{final_url}/pdf", f"{final_url}.pdf", f"{final_url}/full.pdf", final_url.replace("/article/", "/article/pdf/"), ] for pdf_url in pdf_patterns: try: pdf_response = requests.get(pdf_url, timeout=30) if pdf_response.status_code == 200: content = pdf_response.content if content.startswith(b"%PDF-"): self.output_path.write_bytes(content) return True, "期刊官网" except Exception: continue return False, "未找到直接 PDF 链接" except Exception as e: return False, str(e) def _extract_arxiv_id(self) -> Optional[str]: """从 DOI 中提取 arXiv ID""" # 常见的 arXiv DOI 格式 # 10.48550/arXiv.2301.12345 # doi.org/10.48550/arXiv.2301.12345 arxiv_patterns = [ r"arxiv\.(\d+\.\d+)", # arXiv.ID r"arxiv/(\d+\.\d+)", # arXiv/ID ] for pattern in arxiv_patterns: match = re.search(pattern, self.doi.lower()) if match: return match.group(1) # 检查是否是 arXiv DOI 前缀 if self.doi.startswith("10.48550/"): suffix = self.doi.split("/")[-1] if suffix.lower().startswith("arxiv."): parts = suffix.split(".") # arXiv ID 格式: YYMM.NNNNN 或 YYMMNNN if len(parts) >= 3: return f"{parts[-2]}.{parts[-1]}" # 返回字符串而非列表 return None def download(self) -> Tuple[bool, str, str]: """执行多源下载策略""" # 策略 1: arXiv(如果检测到 arXiv ID) arxiv_id = self._extract_arxiv_id() if arxiv_id: self.sources_tried.append("arXiv") success, source = self.download_from_arxiv() if success: return True, source, "成功" # 策略 2: Sci-Hub self.sources_tried.append("Sci-Hub") success, source = self.download_from_scihub() if success: return True, source, "成功" # 策略 3: Unpaywall(合法 OA) self.sources_tried.append("Unpaywall") success, source = self.download_from_unpaywall() if success: return True, source, "成功" # 策略 4: 直接从期刊官网 self.sources_tried.append("期刊官网") success, source = self.download_from_direct() if success: return True, source, "成功" # 所有源失败 tried = ", ".join(self.sources_tried) return False, "", f"尝试过的源: {tried}" def load_config(config_path: Path) -> dict: """加载配置文件""" import yaml if not config_path.exists(): return {} with open(config_path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def main(): if len(sys.argv) < 3: print("用法: python download_pdf.py <doi> <output_path> [config_path]", file=sys.stderr) sys.exit(1) doi = sys.argv[1] output_path = Path(sys.argv[2]).expanduser().resolve() # 加载配置(可选) config = {} if len(sys.argv) >= 4: config_path = Path(sys.argv[3]) config = load_config(config_path) downloader = PDFDownloader(doi, output_path, config) success, source, message = downloader.download() if success: print(f"SUCCESS:{source}") print(f"FILE:{output_path}") print(f"SIZE:{output_path.stat().st_size}") print(f"SOURCES_TRIED:{','.join(downloader.sources_tried)}") return 0 else: print(f"FAILURE:{message}", file=sys.stderr) print(f"SOURCES_TRIED:{','.join(downloader.sources_tried)}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main()) -
validate_input.py 2.2 KB
#!/usr/bin/env python3 """ 输入验证与规范化脚本 """ import sys import re from pathlib import Path def normalize_doi(doi: str) -> str: """规范化 DOI 格式""" doi = doi.strip() # 移除常见前缀 if doi.lower().startswith("doi:"): doi = doi[4:].strip() # 移除 URL 前缀 if "doi.org/" in doi: doi = doi.split("doi.org/")[-1].strip() # 确保以 10. 开头(DOI 标准格式) if not doi.startswith("10."): # 尝试补全 if "." in doi: doi = f"10.{doi}" return doi def validate_output_path(path: str, default_filename: str = "paper.pdf", config: dict = None) -> Path: """验证输出路径,确保路径在当前工作目录范围内(防止路径遍历攻击)""" path = Path(path).expanduser().resolve() cwd = Path.cwd().resolve() # 安全检查:确保路径在当前工作目录或其子目录内(防止路径遍历) try: path.relative_to(cwd) except ValueError: raise ValueError(f"输出路径必须在当前工作目录范围内: {path}") if path.exists() and path.is_dir(): # 目录:自动生成文件名 return path / default_filename # 检查是否允许创建目录(从配置读取) output_config = config or {} create_dirs = output_config.get("create_missing_dirs", True) if path.parent.exists(): # 完整路径:确保父目录可写 return path if not create_dirs: raise ValueError(f"父目录不存在且 create_missing_dirs=false: {path.parent}") # 父目录不存在,尝试创建 try: path.parent.mkdir(parents=True, exist_ok=True) return path except Exception as e: raise ValueError(f"无法创建输出目录: {e}") def main(): if len(sys.argv) < 3: print("用法: python validate_input.py <doi> <output_path>", file=sys.stderr) sys.exit(1) doi = normalize_doi(sys.argv[1]) output_path = validate_output_path(sys.argv[2]) # 输出格式化结果(供 AI 解析) print(f"DOI:{doi}") print(f"OUTPUT:{output_path}") return 0 if __name__ == "__main__": sys.exit(main()) -
verify_pdf.py 2.3 KB
#!/usr/bin/env python3 """ PDF 验证脚本 """ import sys from pathlib import Path from typing import Tuple def verify_pdf(path: Path, require_pypdf2: bool = False) -> Tuple[bool, str]: """验证 PDF 文件完整性""" if not path.exists(): return False, "文件不存在" file_size = path.stat().st_size if file_size == 0: return False, "文件大小为 0" # 检查文件大小范围 max_size = 100_000_000 # 100 MB min_size = 1024 # 1 KB if file_size > max_size: return False, f"文件过大: {file_size} bytes" if file_size < min_size: return False, f"文件过小: {file_size} bytes,可能是错误页面" # 检查 PDF 文件头 try: with open(path, "rb") as f: header = f.read(5) if header != b"%PDF-": # 检查是否是 HTML 错误页面 f.seek(0) content_start = f.read(100).decode("utf-8", errors="ignore") if "<html" in content_start.lower() or "<!doctype" in content_start.lower(): return False, "文件是 HTML 页面,非 PDF" return False, "无效的 PDF 文件头" except Exception as e: return False, f"读取文件头失败: {str(e)}" # 尝试用 PyPDF2 解析 try: import PyPDF2 with open(path, "rb") as f: reader = PyPDF2.PdfReader(f) # 检查页数 if len(reader.pages) == 0: return False, "PDF 无页面" return True, f"有效 PDF ({len(reader.pages)} 页)" except ImportError: if require_pypdf2: return False, "未安装 PyPDF2,无法深度验证" return True, "有效 PDF(未安装 PyPDF2,跳过深度验证)" except Exception as e: return False, f"PDF 解析失败: {str(e)}" def main(): if len(sys.argv) < 2: print("用法: python verify_pdf.py <pdf_path> [--require-pypdf2]", file=sys.stderr) sys.exit(1) pdf_path = Path(sys.argv[1]).expanduser().resolve() require_pypdf2 = "--require-pypdf2" in sys.argv is_valid, message = verify_pdf(pdf_path, require_pypdf2) if is_valid: print(f"VALID:{message}") return 0 else: print(f"INVALID:{message}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())
-
-
CHANGELOG.md 1.5 KB
# Changelog 本文件记录 download-fulltext-pdf skill 的所有重要变更。 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 ## [Unreleased] ### Fixed - Unpaywall 轮询状态文件增加符号链接越界校验;默认配置不再包含个人邮箱,可通过 `UNPAYWALL_EMAILS` 提供速率限制邮箱。 ### Added - 自动安装缺失依赖机制(scihub 库) - 路径遍历安全检查(防止恶意输入访问系统文件) - `create_missing_dirs` 配置项支持 - Unpaywall `emails` + `email_strategy` + `state_file` 配置,支持多邮箱负载均衡 ### Fixed - arXiv ID 提取 bug(返回类型从列表改为字符串) - 路径验证安全漏洞(增加工作目录边界检查) - Unpaywall 仅使用 `best_oa_location.url_for_pdf` 导致“有 OA 但无 PDF”误判的问题(改为遍历 `oa_locations` 候选链接) ### Changed - 更新 README.md 说明开箱即用性 - 更新 SKILL.md 说明自动安装机制 - Unpaywall 请求逻辑支持按状态码(如 429/5xx)自动切换邮箱重试 - 版本号 `0.1.0 → 0.1.1` - 版本号 `0.1.1 → 0.1.2`;Unpaywall round_robin 状态文件从 `.download-fulltext-pdf/` 迁移到 `.bensz-api/skills/download-fulltext-pdf/state/` ## [0.1.0] - 2026-01-24 ### Added - 初始版本发布 - 通过 DOI 号下载学术论文全文 PDF - 支持 Sci-Hub、arXiv、Unpaywall、期刊官网等多源策略 - 智能 arXiv ID 检测 - 下载后 PDF 验证 - 详细的错误处理和报告 -
config.yaml 1.9 KB
# download-fulltext-pdf 配置文件 # 版本号管理:本文件是版本信息的唯一来源(Single Source of Truth) skill_info: name: download-fulltext-pdf version: 0.1.2 description: "通过 DOI 号下载学术论文全文 PDF,支持多源策略(arXiv、Sci-Hub、Unpaywall、期刊官网)" author: "Bensz Conan" category: "文献获取" # 下载策略配置 download: # Sci-Hub 配置 scihub: enabled: true timeout: 30 # 秒 retries: 3 domain: "https://sci-hub.se" # 可根据需要更新 # arXiv 配置 arxiv: enabled: true timeout: 30 base_url: "https://arxiv.org/pdf" # Unpaywall 配置(合法开放获取) unpaywall: enabled: true timeout: 30 api_url: "https://api.unpaywall.org/v2" # Unpaywall 不需要 API key,但建议带 email 参数以获得更稳定的速率限制口径。 # 为了负载均衡(分摊速率限制),支持配置多个邮箱并选择策略。 # # - emails:推荐使用(可配置多个邮箱) # - email:兼容旧字段;当 emails 为空时才会使用 emails: [] # 也可通过 UNPAYWALL_EMAILS=user@example.org,team@example.org 提供。 email_strategy: "round_robin" # round_robin | hash_doi | random state_file: ".bensz-api/skills/download-fulltext-pdf/state/unpaywall_email_state.json" # round_robin 的状态文件(相对 cwd) email: "" # 兼容旧字段(可选) # 验证配置 verification: check_pdf_header: true require_pypdf2: false # 是否强制要求 PyPDF2 max_file_size: 100_000_000 # 100 MB min_file_size: 1024 # 1 KB # 输出配置 output: default_filename: "paper.pdf" overwrite: false # 是否覆盖已存在文件 create_missing_dirs: true # 自动创建缺失的目录 # 日志配置 logging: enabled: true level: "INFO" # DEBUG, INFO, WARNING, ERROR file: "download-fulltext-pdf.log" -
README.md 3.5 KB
# download-fulltext-pdf 这个 skill 用来尽可能把目标论文的全文 PDF 下载到本地,适合 DOI、标题或 BibTeX 驱动的全文获取;如果你已经有 PDF,只是想解析、提取或处理它,就不该用它。 ## 用法 ### 最推荐用法 ```text 请使用 download-fulltext-pdf skill 下载这篇论文的全文 PDF。 输入:DOI、标题或 BibTeX,以及输出目录 输出:下载到本地的 PDF 文件;若失败,给出各数据源的失败原因 ``` ### 进阶用法 ```text 请使用 download-fulltext-pdf skill 获取这篇论文的全文。 输入:DOI `10.1038/nature09492`,输出目录 `./papers` 输出:本地 PDF 文件 另外,还有下列参数约束: - 优先尝试开放获取来源 - 若失败,保留每个数据源的失败原因 - 不覆盖已存在文件 ``` ## 能做什么 - 根据 DOI、标题或 BibTeX 识别目标论文。 - 按多源策略尝试下载 PDF。 - 对下载结果做基本验证,避免把 HTML 错页当成 PDF。 - 失败时给出来源级错误信息,便于你继续排查。 - 不适合处理已有 PDF,也不负责论文内容分析。 ## 使用示例 ### 示例 1:按 DOI 下载 ```text 请使用 download-fulltext-pdf skill 下载这篇论文的全文 PDF。 输入:DOI `10.1038/nature09492`,输出目录 `./papers` 输出:下载好的 PDF 文件 ``` ### 示例 2:按 BibTeX 下载 ```text 请使用 download-fulltext-pdf skill 下载这篇文献。 输入:BibTeX 条目,以及输出目录 `./downloads` 输出:本地 PDF 文件或失败原因报告 ``` ### 示例 3:要求明确失败报告 ```text 请使用 download-fulltext-pdf skill 获取这篇论文的全文。 输入:论文标题和输出目录 输出:PDF 文件 另外,还有下列参数约束: - 如果全部失败,列出每个数据源的失败原因 ``` ## 输出 - 成功时:输出一个本地 `.pdf` 文件。 - 默认文件名:`paper.pdf` - 默认不会覆盖已有输出。 - 验证失败时,会继续尝试下一个数据源。 - 所有来源都失败时,会返回详细失败说明。 ## 配置 - 配置文件:`download-fulltext-pdf/config.yaml` - 常见数据源顺序: - `arXiv` - `Sci-Hub` - `Unpaywall` - 期刊官网兜底 - 默认输出覆盖:`false` - 常见关键配置: - `download.scihub` - `download.arxiv` - `download.unpaywall` - `verification` - `output` ## 备选用法(脚本/硬编码) 如果你已经明确知道 DOI 和目标输出路径,脚本方式最直接。 ### 输入校验 ```bash python3 download-fulltext-pdf/scripts/validate_input.py \ 10.1038/nature09492 \ ./papers ``` ### 执行下载 ```bash python3 download-fulltext-pdf/scripts/download_pdf.py \ 10.1038/nature09492 \ ./papers ``` ### 校验 PDF ```bash python3 download-fulltext-pdf/scripts/verify_pdf.py \ ./papers/paper.pdf ``` ## 常见问题 ### Q:为什么失败报告里会出现多个来源? A:因为这个 skill 会按顺序尝试多个来源,只要前一个失败,就继续尝试下一个。 ### Q:下载到了文件,但打不开怎么办? A:这通常意味着拿到的是 HTML 页面或损坏文件。技能会尽量做 PDF 头和可解析性检查,但你仍可以再运行 `verify_pdf.py` 复核。 ### Q:会覆盖我已经下载好的 PDF 吗? A:默认不会。配置里的 `output.overwrite` 默认为 `false`。 ### Q:它能帮我总结论文吗? A:不能。它的职责是“拿到 PDF”,不是“阅读或分析 PDF”。 -
SKILL.md 15.3 KB
--- name: download-fulltext-pdf description: 当用户明确要求"下载文献全文"或"获取论文PDF"时使用。通过 DOI 号下载学术论文全文 PDF,支持 arXiv、Sci-Hub、Unpaywall、期刊官网等多源策略。⚠️ 不适用:用户只是想解析或处理已有的 PDF 文件(应使用 pdf skill)、只是想搜索论文信息而无需下载全文、没有提供 DOI/标题/BibTeX 任何标识符。 metadata: author: Bensz Conan keywords: - download-fulltext-pdf --- # 下载文献全文 PDF ## 目标 当用户明确要求"下载文献全文"或"获取论文PDF"时使用。通过 DOI 号下载学术论文全文 PDF,支持 arXiv、Sci-Hub、Unpaywall、期刊官网等多源策略。⚠️ 不适用:用户只是想解析或处理已有的 PDF 文件(应使用 pdf skill)、只是想搜索论文信息而无需下载全文、没有提供 DOI/标题/BibTeX 任何标识符。 ## 流程 ### 输入 输入至少包含 DOI、标题或 BibTeX 之一,并指定输出目录/文件路径;可选输入包括来源开关、Unpaywall 邮箱、验证阈值和覆盖策略。输出路径必须存在或可创建且可写,触发边界以本 Skill 的 `## 目标` 为准。 ### 执行步骤 #### 核心工作流 ##### 1. 输入验证与规范化 **必需参数**: - `doi`: DOI 号(如 `10.1038/nature09492`) - `output_path`: 输出目录或完整 PDF 文件路径 **可选参数**: - `title`: 论文标题(作为 DOI 的备选) - `bibtex`: BibTeX 条目(包含 DOI 或标题信息) **验证逻辑**: ```python # 由 scripts/validate_input.py 处理 - DOI 格式规范化(移除多余空格、补全前缀) - 输出路径检查(目录存在性、写入权限) - 至少提供一种标识符(DOI/title/bibtex) ``` ##### 2. 多源下载策略 **优先级顺序**(按成功率和速度排序): | 优先级 | 数据源 | 优势 | 局限 | 触发条件 | |--------|--------|------|------|----------| | **1** | arXiv | 稳定可靠 | 仅限预印本论文 | 检测到 arXiv ID(如 `10.48550/arXiv.*`) | | **2** | Sci-Hub | 覆盖较广、速度快 | 可能有 CAPTCHA | 非 arXiv 或 arXiv 失败 | | **3** | Unpaywall | 合法开放获取(OA) | 取决于论文是否 OA | Sci-Hub 失败时兜底 | | **4** | 期刊官网 | 合法渠道 | 常需订阅/登录 | 最后兜底 | **策略执行**: ```python # 由 scripts/download_pdf.py 实现 1. 如 DOI 可解析出 arXiv ID → 优先 arXiv 2. 尝试 Sci-Hub(通过 scihub 库) 3. 尝试 Unpaywall(合法 OA 获取) 4. 尝试 DOI 落地页并猜测常见 PDF 路径(期刊官网兜底) ``` ##### 3. 下载后验证 **必需检查**(由 `scripts/verify_pdf.py` 处理): - [ ] 文件大小 > 0 且非 HTML 页面 - [ ] PDF 文件头正确(`%PDF-`) - [ ] 文件可被 PyPDF2 解析 **失败处理**: - 验证失败 → 记录错误 → 尝试下一个数据源 - 所有源失败 → 返回详细错误报告 ##### 4. 输出格式 **成功时**: ```markdown ✅ 成功下载论文全文 **来源**: Sci-Hub **DOI**: 10.1038/nature09492 **文件**: /path/to/paper.pdf **大小**: 2.3 MB ``` **失败时**: ```markdown ❌ 无法下载论文全文 **尝试过的源**: - Sci-Hub: CAPTCHA 验证失败 - arXiv: 非 arXiv 论文 - Unpaywall: 无开放获取版本 - 期刊官网: 未找到直接 PDF 链接 **建议**: 1. 手动访问 Sci-Hub 并完成 CAPTCHA 验证 2. 检查 DOI 是否正确 3. 尝试通过期刊官网获取 ``` #### AI 动态判断 AI 需要动态处理的场景: | 场景 | 处理方式 | |------|----------| | **CAPTCHA 检测** | 识别错误消息中的 "captcha" 关键词 → 切换数据源 | | **网络超时** | 增加重试次数(最多 3 次)或切换源 | | **404/未找到** | 直接切换下一个源,不重试 | | **文件损坏** | 删除已下载文件,尝试下一个源 | | **arXiv 检测** | DOI 中包含 arXiv ID → 优先使用 arXiv 源 | #### 硬编码操作(scripts/) ##### scripts/validate_input.py 输入验证与规范化脚本: ```python import sys import re from pathlib import Path def normalize_doi(doi: str) -> str: """规范化 DOI 格式""" doi = doi.strip() if not doi.startswith("10."): doi = f"10.{doi}" # 尝试补全 return doi def validate_output_path(path: str) -> Path: """验证输出路径""" path = Path(path).expanduser().resolve() if path.exists() and path.is_dir(): # 目录:自动生成文件名 return path / "paper.pdf" if path.parent.exists(): # 完整路径:确保父目录可写 return path raise ValueError(f"输出路径无效: {path}") def main(): if len(sys.argv) < 3: print("用法: python validate_input.py <doi> <output_path>", file=sys.stderr) sys.exit(1) doi = normalize_doi(sys.argv[1]) output_path = validate_output_path(sys.argv[2]) print(f"DOI:{doi}") print(f"OUTPUT:{output_path}") if __name__ == "__main__": main() ``` ##### scripts/download_pdf.py 核心下载逻辑(多源策略): ```python import sys import requests from pathlib import Path from typing import Optional, Tuple # 尝试导入 scihub 库(可选依赖) try: from scihub import SciHub HAS_SCIHUB = True except ImportError: HAS_SCIHUB = False class PDFDownloader: """多源 PDF 下载器""" def __init__(self, doi: str, output_path: Path): self.doi = doi self.output_path = output_path self.sources_tried = [] def download_from_scihub(self) -> Tuple[bool, str]: """从 Sci-Hub 下载""" if not HAS_SCIHUB: return False, "scihub 库未安装" try: sh = SciHub() result = sh.download(self.doi, path=str(self.output_path)) if result and self.output_path.exists(): return True, "Sci-Hub" return False, "下载失败" except Exception as e: error_msg = str(e).lower() if "captcha" in error_msg: return False, "CAPTCHA 验证失败" return False, str(e) def download_from_arxiv(self) -> Tuple[bool, str]: """从 arXiv 下载(检测 arXiv ID)""" # 简化实现:arXiv 直接下载 URL arxiv_id = self._extract_arxiv_id() if not arxiv_id: return False, "非 arXiv 论文" url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" try: response = requests.get(url, timeout=30) if response.status_code == 200: self.output_path.write_bytes(response.content) return True, "arXiv" return False, f"HTTP {response.status_code}" except Exception as e: return False, str(e) def download_from_unpaywall(self) -> Tuple[bool, str]: """从 Unpaywall 下载(合法 OA)""" # Unpaywall API 端点 # email 参数建议从 config.yaml 读取;可配置 emails 列表做负载均衡 url = f"https://api.unpaywall.org/v2/{self.doi}" try: response = requests.get(url, timeout=30) if response.status_code == 200: data = response.json() if data.get("is_oa"): pdf_url = data.get("best_oa_location", {}).get("url_for_pdf") if pdf_url: pdf_resp = requests.get(pdf_url, timeout=30) if pdf_resp.status_code == 200: self.output_path.write_bytes(pdf_resp.content) return True, "Unpaywall" return False, "无开放获取版本" except Exception as e: return False, str(e) def _extract_arxiv_id(self) -> Optional[str]: """从 DOI 或上下文中提取 arXiv ID""" # 简化实现:检测 DOI 中是否包含 arXiv 信息 # 实际应解析 BibTeX 或标题 return None def download(self) -> Tuple[bool, str, str]: """执行多源下载策略""" # 策略 1: Sci-Hub self.sources_tried.append("Sci-Hub") success, source = self.download_from_scihub() if success: return True, source, "成功" # 策略 2: arXiv(如果是 arXiv 论文) self.sources_tried.append("arXiv") success, source = self.download_from_arxiv() if success: return True, source, "成功" # 策略 3: Unpaywall self.sources_tried.append("Unpaywall") success, source = self.download_from_unpaywall() if success: return True, source, "成功" # 所有源失败 return False, "", f"尝试过的源: {', '.join(self.sources_tried)}" def main(): if len(sys.argv) != 3: print("用法: python download_pdf.py <doi> <output_path>", file=sys.stderr) sys.exit(1) doi = sys.argv[1] output_path = Path(sys.argv[2]).expanduser().resolve() downloader = PDFDownloader(doi, output_path) success, source, message = downloader.download() if success: print(f"SUCCESS:{source}") print(f"FILE:{output_path}") print(f"SIZE:{output_path.stat().st_size}") else: print(f"FAILURE:{message}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() ``` ##### scripts/verify_pdf.py PDF 验证脚本: ```python import sys from pathlib import Path def verify_pdf(path: Path) -> Tuple[bool, str]: """验证 PDF 文件完整性""" if not path.exists(): return False, "文件不存在" if path.stat().st_size == 0: return False, "文件大小为 0" # 检查 PDF 文件头 with open(path, "rb") as f: header = f.read(5) if header != b"%PDF-": return False, "无效的 PDF 文件头" # 尝试用 PyPDF2 解析 try: import PyPDF2 with open(path, "rb") as f: PyPDF2.PdfReader(f) return True, "有效 PDF" except ImportError: # PyPDF2 未安装,跳过深度验证 return True, "未安装 PyPDF2,跳过深度验证" except Exception as e: return False, f"PDF 解析失败: {str(e)}" def main(): if len(sys.argv) != 2: print("用法: python verify_pdf.py <pdf_path>", file=sys.stderr) sys.exit(1) path = Path(sys.argv[1]).expanduser().resolve() is_valid, message = verify_pdf(path) if is_valid: print(f"VALID:{message}") sys.exit(0) else: print(f"INVALID:{message}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() ``` #### 配置参数(config.yaml) ```yaml # 下载策略配置 download: # Sci-Hub 配置 scihub: enabled: true timeout: 30 # 秒 retries: 3 # arXiv 配置 arxiv: enabled: true timeout: 30 # Unpaywall 配置 unpaywall: enabled: true emails: - "hwb2012@qq.com" - "huangwb886@gmail.com" email_strategy: "round_robin" # round_robin | hash_doi | random state_file: ".bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/download-fulltext-pdf/state/unpaywall_email_state.json" email: "" # 兼容旧字段(可选) # 验证配置 verification: check_pdf_header: true require_pypdf2: false # 是否强制要求 PyPDF2 max_file_size: 100_000_000 # 100 MB # 输出配置 output: default_filename: "paper.pdf" overwrite: false # 是否覆盖已存在文件 ``` #### 依赖说明 **必需依赖**: - `requests`(网络请求) **可选依赖(自动安装)**: - `scihub`(Sci-Hub 支持,**首次使用时自动安装**) - `PyPDF2`(PDF 验证,推荐安装) ##### 自动安装机制 当 AI 执行下载任务时,`scripts/download_pdf.py` 会: 1. 检测缺失的可选依赖 2. **提醒用户**将要安装的包(如 `📦 检测到缺失依赖: scihub`) 3. **自动安装**(使用 `pip install`) 4. 安装失败时自动降级到其他数据源 用户无需手动安装,skill 会"开箱即用"。 ##### 手动安装(可选) 如果自动安装失败,用户可以手动安装: ```bash pip install requests scihub PyPDF2 ``` #### 使用示例 **示例 1:使用 DOI 下载** ```bash /skill download-fulltext-pdf "10.1038/nature09492" ./downloads/ ``` **示例 2:使用标题下载** ```bash /skill download-fulltext-pdf --title "Deep Learning" ./downloads/ ``` **示例 3:批量下载(从 BibTeX 文件)** ```bash /skill download-fulltext-pdf --bibtex references.bib ./downloads/ ``` ### 输出 成功时交付经 `%PDF-` 文件头和可选 PyPDF2 解析验证的 PDF,并报告来源、规范化 DOI、文件路径和大小;失败时列出尝试过的来源、具体原因和可操作建议。状态文件、日志和调试材料留在任务工作区。 ### 输出管理 #### BenszAPI 任务工作区 ### 校验 #### 质量检查清单 - [ ] DOI 格式正确(以 `10.` 开头) - [ ] 输出路径可写 - [ ] 至少一种数据源可用 - [ ] 下载后 PDF 验证通过 - [ ] 错误消息清晰可操作 ### 失败与恢复 #### 错误处理 | 错误类型 | 表现 | 处理方式 | |---------|------|----------| | **CAPTCHA** | Sci-Hub 返回验证码页面 | 切换到 Unpaywall 或期刊官网 | | **未找到** | 404 或 "not found" | 切换下一个数据源 | | **超时** | 请求超时 | 重试(最多 3 次) | | **文件损坏** | PDF 头无效 | 删除并重新下载 | #### 注意事项 1. **版权合规**:仅用于学术研究,遵守当地版权法律 2. **Sci-Hub 可用性**:Sci-Hub 域名可能变化,需要定期更新 3. **网络环境**:某些地区可能需要代理才能访问 Sci-Hub 4. **CAPTCHA 处理**:遇到 CAPTCHA 时,建议手动完成验证 ## 约束 <!-- BEGIN COMMON CONSTRAINTS --> <!-- Source-Hash: sha256:15120201e9e0c7569517261d57ecefb63ac279c26ed13876f8e95b6dc35854d3 --> <!-- Template-ID: skill-common-constraints; Template-Version: 1; Sync-Policy: exact-block --> ### 公共硬约束 本块由 `docs/templates/skill-common-constraints.md` 统一维护;每个 `SKILL.md` 的 `## 约束` 必须逐字同步本块,不得在副本中改写公共规则。 - 任务需要落盘时,使用唯一的 `./.bensz-api/task-{yyyymmdd-hhmm}-{简短描述}/` 根目录;共享材料放入 `shared/`,Skill 专属材料放入该 Skill 的 `input/`、`output/`、`log/`。 - 正式交付物、源代码和正式计划按项目约定保存,不写入任务工作区;未经授权不覆盖、删除、迁移或远程写入。 - 项目维护变更检查 BAC 可用性并记录需求、AI 产出、工具结果、文件改动和验证摘要;BAC 只做过程审计,不替代署名、责任或合规判断。 - 不记录 API Key、访问令牌、密码、Cookie、环境/凭据文件、私有 Prompt、身份信息、本地用户名、主机名或不必要的大体积原始数据。 - 文件路径必须规范化并限制在授权项目范围内;外部 URL、子进程和网络访问遵循最小权限,防止路径遍历、SSRF 和命令注入。 - Skill 版本唯一记录在自身 `config.yaml:skill_info.version`;公开 API、协议、目录或配置变更同步文档与 `CHANGELOG.md`。 - `bensz-collect-bugs` 是一个 Agent Skill;仅将 Bensz Agent Skill 或 Bensz 基础设施本身的设计缺陷交给它。先脱敏写入 `~/.bensz-skills/bugs/`,当前任务不中断,只有用户明确要求才公开上报,禁止直接修改用户已安装的 Skill 源码。 <!-- End of canonical common constraints. --> <!-- END COMMON CONSTRAINTS -->
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.