#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 学术论文批量下载工具 (Academic Paper Downloader) 支持HTTP(S)/HTTPS重试、大文件分块下载、进度显示、日志记录 创建时间:2026-04-14 最后更新:2026-04-14 """ import urllib.request import ssl import os import sys import io import time import json from pathlib import Path from urllib.error import URLError, HTTPError from datetime import datetime # Force UTF-8 encoding for console output sys.stdout = io.TextIOWrapper(sys.stdout, encoding="utf-8") # ============================================================================= # 配置区域 # ============================================================================= # 基础目录 BASE_DIR = r"D:\TC_UP\2023card\projects\openclaw\教育AI研究\高等教育AI专题\文献库" LOG_DIR = os.path.join(BASE_DIR, "logs") CONFIG_DIR = os.path.join(BASE_DIR, "configs") os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(CONFIG_DIR, exist_ok=True) # 下载配置 class DownloadConfig: """下载配置""" def __init__(self): self.timeout = 300 # 5分钟 self.retry_count = 3 self.retry_delay = 5 # 秒 self.chunk_size = 8192 # 8KB per chunk self.user_agent = "Mozilla/5.0 (compatible; AcademicPaperDownloader/1.0)" def set_timeout(self, seconds): """设置超时时间""" self.timeout = seconds def set_retry(self, count, delay): """设置重试次数和延迟""" self.retry_count = count self.retry_delay = delay # SSL上下文 SSL_CONTEXT = ssl._create_default_https_context() # ============================================================================= # 日志系统 # ============================================================================= class DownloadLogger: """下载日志记录器""" def __init__(self, log_file): self.log_file = os.path.join(LOG_DIR, log_file) self.records = [] self._load_existing_logs() def _load_existing_logs(self): """加载已有日志""" if os.path.exists(self.log_file): try: with open(self.log_file, "r", encoding="utf-8") as f: for line in f: if line.strip(): try: self.records.append(json.loads(line)) except: pass except: pass def log(self, paper_name, url, status, size_mb, error=None): """记录下载日志""" record = { "timestamp": datetime.now().isoformat(), "paper": paper_name, "url": url, "status": status, # 'success' or 'failed' "size_mb": size_mb, "error": error, } self.records.append(record) self._save_log() def _save_log(self): """保存日志到文件""" with open(self.log_file, "w", encoding="utf-8") as f: for record in self.records: f.write(json.dumps(record, ensure_ascii=False) + "\n") def get_stats(self): """获取统计信息""" total = len(self.records) success = len([r for r in self.records if r["status"] == "success"]) failed = len([r for r in self.records if r["status"] == "failed"]) total_size = sum( [r.get("size_mb", 0) for r in self.records if r["status"] == "success"] ) return { "total": total, "success": success, "failed": failed, "success_rate": f"{(success / total * 100):.1f}%", "total_size_mb": f"{total_size:.2f}", "avg_size_mb": f"{(total_size / success if success > 0 else 0):.2f}", } # ============================================================================= # 核心下载器 # ============================================================================= class PaperDownloader: """学术论文下载器""" def __init__(self, config): self.config = config self.logger = DownloadLogger("paper_downloads.log") def download_with_retry(self, url, filepath, paper_name=""): """带重试机制的下载""" filename = os.path.basename(filepath) for attempt in range(self.config.retry_count): try: print(f"\n📥 尝试 {attempt + 1}/{self.config.retry_count}: {filename}") return self._download(url, filepath, paper_name) except (URLError, HTTPError) as e: print(f"❌ 尝试 {attempt + 1}/{self.config.retry_count} 失败: {e}") if attempt < self.config.retry_count - 1: print(f"⏳ {self.config.retry_delay} 秒后重试...") time.sleep(self.config.retry_delay) else: # 最后一次失败,记录日志 self.logger.log( paper_name or filename, url, "failed", 0, error=str(e) ) return False def _download(self, url, filepath, paper_name=""): """实际下载方法,支持分块下载""" try: print(f"📥 开始下载: {paper_name or os.path.basename(filepath)}") req = urllib.request.Request( url, headers={"User-Agent": self.config.user_agent} ) with urllib.request.urlopen( req, context=SSL_CONTEXT, timeout=self.config.timeout ) as response: total_size = int(response.headers.get("content-length", 0)) total_mb = total_size / (1024 * 1024) print(f"📊 文件大小: {total_mb:.2f} MB") # 创建目录 os.makedirs(os.path.dirname(filepath), exist_ok=True) downloaded = 0 start_time = time.time() with open(filepath, "wb") as f: while downloaded < total_size: chunk = response.read(self.config.chunk_size) if not chunk: break f.write(chunk) downloaded += len(chunk) # 进度显示 percent = (downloaded / total_size) * 100 downloaded_mb = downloaded / (1024 * 1024) elapsed = time.time() - start_time # 进度条 progress_bar = "█" * int(percent / 5) print( f"\r进度: {percent:5.1f}% [{progress_bar:20}] {downloaded_mb:.2f}/{total_mb:.2f}MB {elapsed:.0f}s", end="", ) print() # 换行 # 记录成功日志 self.logger.log( paper_name or os.path.basename(filepath), url, "success", total_mb ) print( f"\n✅ 下载成功: {paper_name or os.path.basename(filepath)} ({total_mb:.2f} MB)" ) return True except Exception as e: print(f"\n❌ 下载失败: {e}") return False # ============================================================================= # 配置文件管理 # ============================================================================= def load_config(config_file): """从JSON文件加载配置""" config_path = os.path.join(CONFIG_DIR, config_file) if not os.path.exists(config_path): print(f"⚠️ 配置文件不存在: {config_file}") return None try: with open(config_path, "r", encoding="utf-8") as f: config_data = json.load(f) print(f"✅ 已加载配置: {config_file}") print(f"📋 论文数量: {len(config_data.get('papers', []))}") return config_data except Exception as e: print(f"❌ 加载配置失败: {e}") return None def create_sample_config(): """创建示例配置文件(使用英文以避免编码问题)""" sample_config = { "name": "Academic Paper Downloader Configuration Sample", "description": "Configuration file for batch downloading academic papers", "papers": [ { "name": "Teaching CS50 with AI (SIGCSE 2024)", "url": "https://cs.harvard.edu/malan/publications/V1fp0567-liu.pdf", "filename": "Teaching_CS50_with_AI_SIGCSE2024.pdf", "enabled": True, }, { "name": "Improving AI in CS50 (SIGCSE 2025)", "url": "https://cs.harvard.edu/malan/publications/fp0627-liu.pdf", "filename": "Improving_AI_in_CS50_SIGCSE2025.pdf", "enabled": True, }, ], } sample_path = os.path.join(CONFIG_DIR, "sample_config.json") try: with open(sample_path, "w", encoding="utf-8") as f: json.dump(sample_data, f, ensure_ascii=False, indent=2) print("Sample config created successfully: " + sample_path) return sample_path except Exception as e: print(f"❌ 创建示例配置失败: {e}") return None # ============================================================================= # 主命令 # ============================================================================= def download_from_config(config_file): """从配置文件批量下载""" config_data = load_config(config_file) if not config_data: return downloader = PaperDownloader(DownloadConfig()) papers = config_data.get("papers", []) enabled_papers = [p for p in papers if p.get("enabled", True)] if not enabled_papers: print("⚠️ 没有启用的论文可下载") return print(f"\n{'=' * 50}") print(f"准备下载 {len(enabled_papers)} 篇论文...\n") success_count = 0 failed_count = 0 for idx, paper in enumerate(enabled_papers, 1): print( f"\n[{'=' * 40}/{len(enabled_papers)}] {paper.get('name', 'Unknown Paper')}" ) url = paper.get("url") filename = paper.get("filename", f"paper_{idx}.pdf") filepath = os.path.join(BASE_DIR, filename) name = paper.get("name", "Unknown Paper") if downloader.download_with_retry(url, filepath, name): success_count += 1 else: failed_count += 1 print(f"\n{'=' * 50}") print(f"✅ 成功: {success_count}") print(f"❌ 失败: {failed_count}") # 显示统计 stats = downloader.logger.get_stats() print(f"\n📊 下载统计:") print(f"总论文数: {stats['total']}") print(f"成功率: {stats['success_rate']}") print(f"总下载大小: {stats['total_size_mb']} MB") print(f"平均大小: {stats['avg_size_mb']} MB") def download_single(url, filename=None): """单篇论文下载""" if not filename: # 从URL提取文件名 filename = url.split("/")[-1] if not filename.endswith(".pdf"): filename = filename + ".pdf" filepath = os.path.join(BASE_DIR, filename) paper_name = os.path.splitext(filename)[0] downloader = PaperDownloader(DownloadConfig()) downloader.download_with_retry(url, filepath, paper_name) # 显示统计 stats = downloader.logger.get_stats() print(f"\n📊 当前下载统计:") print(f"总下载次数: {stats['total']}") print(f"成功率: {stats['success_rate']}") print(f"总下载大小: {stats['total_size_mb']} MB") def show_logs(): """显示下载日志""" stats = DownloadLogger("paper_downloads.log").get_stats() print(f"\n📊 下载日志统计:") print(f"{'=' * 40}") print(f"总下载次数: {stats['total']}") print(f"成功次数: {stats['success']}") print(f"失败次数: {stats['failed']}") print(f"成功率: {stats['success_rate']}") print(f"总下载大小: {stats['total_size_mb']} MB") print(f"平均大小: {stats['avg_size_mb']} MB") print(f"日志文件: {os.path.join(LOG_DIR, 'paper_downloads.log')}") def clear_logs(): """清除日志""" log_file = os.path.join(LOG_DIR, "paper_downloads.log") try: if os.path.exists(log_file): os.remove(log_file) print(f"✅ 日志已清除: {log_file}") else: print("⚠️ 日志文件不存在") except Exception as e: print(f"❌ 清除日志失败: {e}") # ============================================================================= # 主程序 # ============================================================================= def print_help(): """显示帮助信息""" help_text = """ 学术论文批量下载工具 (Academic Paper Downloader) 使用方法: python paper_downloader.py [options] 命令: download 下载单篇论文 config 从配置文件批量下载 logs 显示下载日志统计 clear 清除下载日志 create-sample 创建示例配置文件 help 显示此帮助信息 选项: --timeout 设置超时时间(默认: 300秒) --retry 设置重试次数(默认: 3次) --chunk-size 设置分块大小(默认: 8192字节) --no-progress 不显示进度条 示例: # 下载单篇论文 python paper_downloader.py download https://cs.harvard.edu/malan/publications/V1fp0567-liu.pdf # 从配置文件批量下载 python paper_downloader.py config my_papers.json # 创建示例配置文件 python paper_downloader.py create-sample # 查看下载日志 python paper_downloader.py logs # 清除下载日志 python paper_downloader.py clear 配置文件格式: { "name": "论文集合名称", "papers": [ { "name": "论文标题", "url": "论文URL", "filename": "保存的文件名.pdf", "enabled": true } ] } 作者: Sisyphus 版本: 1.0.0 更新: 2026-04-14 """ print(help_text) def main(): """主函数""" if len(sys.argv) < 2: print_help() return command = sys.argv[1].lower() # 解析选项 options = {"timeout": 300, "retry": 3, "chunk_size": 8192, "no_progress": False} args = sys.argv[2:] i = 0 while i < len(args): if args[i] == "--timeout": if i + 1 < len(args): options["timeout"] = int(args[i + 1]) i += 2 elif args[i] == "--retry": if i + 1 < len(args): options["retry"] = int(args[i + 1]) i += 2 elif args[i] == "--chunk-size": if i + 1 < len(args): options["chunk_size"] = int(args[i + 1]) i += 2 elif args[i] == "--no-progress": options["no_progress"] = True i += 1 else: print(f"⚠️ 未知选项: {args[i]}") i += 1 # 应用配置 config = DownloadConfig() if options["timeout"]: config.set_timeout(options["timeout"]) if options["retry"]: config.set_retry( options["retry"], options.get("retry_delay", config.retry_delay) ) if options["chunk_size"]: config.chunk_size = options["chunk_size"] if options["no_progress"]: config.user_agent = ( "Mozilla/5.0 (compatible; AcademicPaperDownloader/1.0) (No Progress)" ) # 执行命令 try: if command == "download": if len(args) > i: download_single(args[i]) else: print("❌ 请提供论文URL") elif command == "config": if len(args) > i: download_from_config(args[i]) else: print("❌ 请提供配置文件路径") elif command == "logs": show_logs() elif command == "clear": clear_logs() elif command == "create-sample": create_sample_config() elif command == "help": print_help() else: print(f"❌ 未知命令: {command}") print_help() except KeyboardInterrupt: print("\n\n⚠️ 下载已中断") except Exception as e: print(f"\n❌ 发生错误: {e}") if __name__ == "__main__": main()